"""
=============================================================================
CS329Z · 前置讲 P1 共享模块：os_lab.py —— 进程、Shell 命令分析、工作区路径、给智能体用的迷你终端
=============================================================================

对应讲义：lectures/prereq1_processes_shell_files.md

【这个文件要回答的问题】
    智能体的"终端工具"背后到底在做什么？——启动一个进程，把模型写的一行命令交给 Shell 执行，
    收集 stdout / stderr / 退出码，在超时的时候把它（和它的子孙）杀掉，再决定模型能看到多少输出。
    这个文件把这些步骤拆开，每一步写成一个可以单独试验的函数：

    run()               ：用"参数列表"启动一个程序（不经过 Shell），带超时、可传入 stdin 和环境变量
    run_shell()         ：把一整行命令交给 /bin/sh 执行（支持管道、重定向……也带来了所有的风险）
    analyze_command()   ：不执行，只**分析**一行 Shell 命令：拆成几个简单命令，找出会被运行的程序，
                          标出用到的特性（管道、重定向、命令替换、here-document、后台运行……）
    check_allowlist()   ：根据分析结果判断这行命令能不能放行（程序白名单 + 禁用的特性）
    scrubbed_env()      ：造一个"干净"的环境变量字典，不把密钥之类的东西传给子进程
    resolve_in_workspace()：把模型给的路径解析成真实路径，并确认它没有逃出工作区（含符号链接）
    truncate_head_tail() / spill_to_file()：命令输出太长时的两种"观察策略"
    Terminal            ：把以上几层组合成一个给智能体用的终端工具（第 4 讲和原课 HW1 的 run_terminal 的雏形）

【重要提醒】
    analyze_command 不是完整的 POSIX Shell 解析器，只覆盖最常见的语法。它的用途是"安全检查"：
    **看不懂的就拒绝**，而不是"尽量猜"。真正的隔离要靠操作系统的沙箱（前置讲 P4）。

【运行】python3 os_lab.py   # 自检：分析几条命令、在临时工作区里跑几条命令
【依赖】只用 Python 标准库；只在 macOS / Linux 上测试过（Windows 没有 /bin/sh 和进程组）。
"""

import os
import re
import signal
import subprocess
import tempfile
import time
from dataclasses import dataclass, field

# 原课 HW1 的沙箱只放行 python3 和"标准文本工具"；这里的默认白名单是同样的思路
# （cd 是 Shell 的内建命令，只改变这一个 Shell 进程的工作目录，放进白名单是为了允许 cd docs && ls 这种写法）
DEFAULT_ALLOWED = ("python3", "ls", "cat", "head", "tail", "wc", "grep", "sort", "uniq", "cut",
                   "tr", "echo", "printf", "find", "awk", "sed", "jq", "pwd", "true", "false", "cd")
# 默认拒绝的 Shell 特性：命令替换、here-document、子 Shell、后台运行（理由见讲义 §4.5）
DEFAULT_REJECTED_FEATURES = ("command_substitution", "heredoc", "subshell", "background")
SECRET_HINTS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTH")


# ============================================================================ 1. 运行进程
@dataclass
class ProcResult:
    """
    一次进程运行的结果。

    【字段】
        argv      ：实际执行的命令（列表）
        stdout / stderr：两条输出流的全部内容（字符串）
        exit_code ：退出码。0 = 成功；正数 = 程序自己报告的失败；
                    负数 = 被信号杀死（Python 的约定：-9 表示被 SIGKILL 杀死）
        timed_out ：是否因为超时被我们杀掉
        seconds   ：花了多少秒（墙钟时间，每次运行都会略有不同）
    """
    argv: list
    stdout: str
    stderr: str
    exit_code: int
    timed_out: bool = False
    seconds: float = 0.0


def run(argv, cwd=None, env=None, timeout=10.0, input_text=None, kill_group=True, drain_timeout=2.0):
    """
    用"参数列表"启动一个程序，等它结束（或超时），收集它的输出。

    【思路】subprocess.Popen 启动子进程；communicate() 一边往 stdin 写、一边读 stdout / stderr，
        直到子进程退出或者超时。超时以后要把子进程杀掉——而且要**连同它启动的子孙一起杀**，
        否则一个在后台运行的孙进程会一直占着输出管道，让我们永远等不到"输出结束"（讲义 §7.3）。
        start_new_session=True 让子进程成为一个新的"进程组"的组长，之后 os.killpg 可以一次杀掉整组。
    【参数】
        argv      ：参数列表，例如 ["python3", "-c", "print(1)"]。第一个元素是程序名。
                    **不经过 Shell**：参数里的空格、分号、$ 都只是普通字符。
        cwd       ：子进程的工作目录（None = 继承当前目录）
        env       ：子进程的环境变量字典（None = 继承当前进程的全部环境变量，包括密钥！）
        timeout   ：最多等几秒
        input_text：写进子进程 stdin 的文字（None = 不提供输入，子进程读 stdin 会立刻读到结束）
        kill_group：超时时是否杀掉整个进程组（False 只杀子进程本身，用来演示孙进程的问题）
        drain_timeout：杀掉之后，再等几秒把管道里剩下的输出读完
    【返回】ProcResult
    【边界】
        - 程序不存在：返回 exit_code=127；没有执行权限：返回 126（和 Shell 的约定一样），stderr 里写明原因，不抛异常
        - 输出很大：会全部读进内存。真正的终端工具应该限制大小（见 Terminal 的输出策略）
    【Python 小知识】time.monotonic() 是"只增不减"的时钟，适合测量耗时（不受系统改时间影响）。
    """
    t0 = time.monotonic()
    try:
        p = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE, text=True, start_new_session=True)
    except FileNotFoundError as e:
        return ProcResult(list(argv), "", f"command not found: {e.filename}", 127, False, 0.0)
    except PermissionError as e:                     # 文件存在但没有执行权限
        return ProcResult(list(argv), "", f"permission denied: {e.filename}", 126, False, 0.0)
    try:
        out, err = p.communicate(input=input_text, timeout=timeout)
        timed_out = False
    except subprocess.TimeoutExpired:
        timed_out = True
        _kill(p, kill_group)
        try:
            out, err = p.communicate(timeout=drain_timeout)
        except subprocess.TimeoutExpired:            # 只杀了子进程、孙进程还占着管道：放弃读取
            out, err = "", "(output pipes still held open by a surviving descendant process)"
            for f in (p.stdin, p.stdout, p.stderr):  # 我们这一头的管道要自己关掉，否则会泄漏文件描述符
                if f is not None:
                    f.close()
            try:
                p.wait(timeout=drain_timeout)
            except subprocess.TimeoutExpired:
                pass
    return ProcResult(list(argv), out or "", err or "", p.returncode if p.returncode is not None else -9,
                      timed_out, time.monotonic() - t0)


def _kill(p, kill_group):
    """杀掉子进程：kill_group=True 时用 SIGKILL 杀整个进程组，否则只杀子进程本身。"""
    try:
        if kill_group:
            os.killpg(p.pid, signal.SIGKILL)
        else:
            p.kill()
    except (ProcessLookupError, PermissionError):
        pass


def run_shell(cmd, **kwargs):
    """
    把一整行命令交给 /bin/sh 执行：等价于在终端里敲这一行。

    【参数】cmd：命令字符串；其余参数同 run()
    【返回】ProcResult
    【工程联系】智能体的终端工具几乎都这样实现，因为模型写的命令常常带管道（grep ... | wc -l）。
        代价是：Shell 会解释命令里的一切特殊字符——这正是 analyze_command / check_allowlist 要先检查的原因。
    """
    return run(["/bin/sh", "-c", cmd], **kwargs)


def pid_alive(pid):
    """
    某个进程还活着吗？

    【思路】os.kill(pid, 0) 不发送任何信号，只检查"能不能给它发信号"：进程不存在会抛 ProcessLookupError。
    【边界】进程已经结束但还没被父进程"收尸"（僵尸进程）时也算活着；我们的演示里不会遇到这种情况。
    """
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True


# ============================================================================ 2. 分析一行 Shell 命令
@dataclass
class CommandAnalysis:
    """
    analyze_command 的结果。

    【字段】
        commands ：简单命令的列表，每个是一个"词"的列表（引号已去掉，但**没有**做变量展开等）
        programs ：会被运行的程序名（每个简单命令的第一个非赋值的词；包括命令替换里的程序——
                   它们排在外层命令前面，因为 Shell 会先执行命令替换，再用结果拼出外层命令）
        features ：用到的 Shell 特性的集合，可能的值：
                   pipe（|）、and_or（&& ||）、sequence（; 或换行）、background（&）、
                   redirect（> >> < 2> 等）、heredoc（<<）、command_substitution（$(...) 或反引号）、
                   subshell（( ... )）、variable（$VAR）、glob（* ? [）、tilde（~）、assignment（FOO=bar cmd）
        errors   ：分析时发现的问题（例如引号没闭合）；非空时应该拒绝这条命令
    """
    commands: list = field(default_factory=list)
    programs: list = field(default_factory=list)
    features: set = field(default_factory=set)
    errors: list = field(default_factory=list)


_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
_OPERATORS = ("&&", "||", ">>", "<<", "2>", "&>", "|", ";", "&", ">", "<", "(", ")")


def _find_closing_paren(s, i):
    """从 s[i]（紧跟在 "$(" 之后）开始，找到匹配的 ")" 的位置；考虑嵌套和引号。找不到返回 -1。"""
    depth, quote = 1, None
    while i < len(s):
        c = s[i]
        if quote:
            if c == quote:
                quote = None
            elif c == "\\" and quote == '"':
                i += 1
        elif c in "'\"":
            quote = c
        elif c == "\\":
            i += 1
        elif c == "(":
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    return -1


def analyze_command(cmd):
    """
    不执行，只分析一行 Shell 命令。

    【思路】一个按字符扫描的小状态机，模仿 Shell 读命令的方式：
        - 引号外的空白把"词"分开；单引号里的一切都是普通字符；双引号里 $ 和反引号**仍然有效**；
          反斜杠让下一个字符变成普通字符
        - 引号外的 | && || ; & > >> < << ( ) 是运算符：它们把命令分成几个"简单命令"，或者表示重定向
        - $( ... ) 和 `...` 是命令替换：里面是一条完整的命令，会被**先执行**，所以要递归分析，
          把里面的程序也算进 programs（否则 echo $(python3 -c ...) 会被误认为只运行了 echo）
        - 重定向后面跟的那个词是文件名，不是程序
    【参数】cmd：命令字符串
    【返回】CommandAnalysis
    【边界】
        - 引号或 $( 没有闭合 → errors 里记一条
        - here-document（<<）之后的内容不再分析（Shell 会把后面几行当作输入数据）
        - 不支持的语法（例如 case、函数定义、进程替换 <(...)）可能被分析错——用于安全检查时，
          调用方应该对任何"不在预期之内"的特性一律拒绝
    """
    res = CommandAnalysis()
    words, cur, cur_started = [], [], False
    expect_redirect_target = False
    i, n = 0, len(cmd)

    def end_word():
        nonlocal cur, cur_started, expect_redirect_target
        if cur_started:
            w = "".join(cur)
            if expect_redirect_target:
                expect_redirect_target = False      # 重定向的目标是文件名，不是命令的一部分
            else:
                words.append(w)
        cur, cur_started = [], False

    def end_command():
        end_word()
        if words:
            res.commands.append(list(words))
            prog = next((w for w in words if not _ASSIGN_RE.match(w)), None)
            if any(_ASSIGN_RE.match(w) for w in words[:1]):
                res.features.add("assignment")
            if prog is not None:
                res.programs.append(prog)
        words.clear()

    def substitution(inner):
        res.features.add("command_substitution")
        sub = analyze_command(inner)
        res.programs.extend(sub.programs)
        res.features |= sub.features
        res.errors.extend(sub.errors)

    while i < n:
        c = cmd[i]
        if c == "\\":                                   # 反斜杠：下一个字符按原样
            if i + 1 < n:
                cur.append(cmd[i + 1])
                cur_started = True
            i += 2
            continue
        if c == "'":                                    # 单引号：直到下一个单引号，全是普通字符
            j = cmd.find("'", i + 1)
            if j < 0:
                res.errors.append("unclosed single quote")
                break
            cur.append(cmd[i + 1:j])
            cur_started = True
            i = j + 1
            continue
        if c == '"':                                    # 双引号：$ 和反引号仍然有效
            j = i + 1
            while j < n and cmd[j] != '"':
                if cmd[j] == "\\":
                    j += 2
                    continue
                if cmd.startswith("$(", j):
                    k = _find_closing_paren(cmd, j + 2)
                    if k < 0:
                        res.errors.append("unclosed $( inside double quotes")
                        j = n
                        break
                    substitution(cmd[j + 2:k])
                    j = k + 1
                    continue
                if cmd[j] == "`":
                    k = cmd.find("`", j + 1)
                    if k < 0:
                        res.errors.append("unclosed backtick inside double quotes")
                        j = n
                        break
                    substitution(cmd[j + 1:k])
                    j = k + 1
                    continue
                if cmd[j] == "$" and j + 1 < n and (cmd[j + 1].isalnum() or cmd[j + 1] in "_{?@*#"):
                    res.features.add("variable")
                j += 1
            if j >= n:
                if "unclosed" not in " ".join(res.errors):
                    res.errors.append("unclosed double quote")
                break
            cur.append(cmd[i + 1:j])
            cur_started = True
            i = j + 1
            continue
        if cmd.startswith("$(", i):                      # 命令替换 $( ... )
            k = _find_closing_paren(cmd, i + 2)
            if k < 0:
                res.errors.append("unclosed $(")
                break
            substitution(cmd[i + 2:k])
            cur.append("$(...)")
            cur_started = True
            i = k + 1
            continue
        if c == "`":                                    # 命令替换（旧写法）
            k = cmd.find("`", i + 1)
            if k < 0:
                res.errors.append("unclosed backtick")
                break
            substitution(cmd[i + 1:k])
            cur.append("`...`")
            cur_started = True
            i = k + 1
            continue
        if c == "$" and i + 1 < n and (cmd[i + 1].isalnum() or cmd[i + 1] in "_{?@*#"):
            res.features.add("variable")
        if c == "#" and not cur_started:                # 注释：这一行剩下的都不算
            break
        if c in " \t":
            end_word()
            i += 1
            continue
        if c == "\n":
            res.features.add("sequence")
            end_command()
            i += 1
            continue
        op = next((o for o in _OPERATORS if cmd.startswith(o, i)), None)
        if op == "2>" and cur_started:                  # 例如 file2>x 里的 2 是词的一部分
            op = None
        if op is not None:
            end_word()
            if op == "<<":
                res.features.add("heredoc")
                end_command()
                return _finish(res)                     # here-document 之后是数据，不再分析
            if op in (">", ">>", "<", "2>", "&>"):
                res.features.add("redirect")
                expect_redirect_target = True
                if cmd.startswith("&1", i + len(op)) or cmd.startswith("&2", i + len(op)):
                    i += 2                              # 2>&1：把 stderr 合并到 stdout，不需要文件名
                    expect_redirect_target = False
            else:
                end_command()
                res.features.add({"|": "pipe", "&&": "and_or", "||": "and_or", ";": "sequence",
                                  "&": "background", "(": "subshell", ")": "subshell"}[op])
            i += len(op)
            continue
        if c in "*?[":                                  # 通配符：Shell 会把它展开成匹配的文件名
            res.features.add("glob")
        if c == "~" and not cur_started:
            res.features.add("tilde")
        cur.append(c)
        cur_started = True
        i += 1
    end_command()
    return _finish(res)


def _finish(res):
    """去掉 programs 里的重复项（保持顺序），返回结果。"""
    seen = []
    for p in res.programs:
        if p not in seen:
            seen.append(p)
    res.programs = seen
    return res


def check_allowlist(cmd, allowed=DEFAULT_ALLOWED, rejected_features=DEFAULT_REJECTED_FEATURES):
    """
    这行命令能不能放行？

    【思路】三条规则，任何一条不满足就拒绝：
        1. 能被完整分析（没有 errors）
        2. 没有用到被禁止的特性（默认：命令替换、here-document、子 Shell、后台运行）
        3. **每一个**会被运行的程序都在白名单里，而且是裸名字（不能写成 ./python3 或 /tmp/x/python3）
    【参数】cmd；allowed：白名单；rejected_features：禁止的特性
    【返回】(ok: bool, reason: str)。reason 会原样交给模型看，所以要写得让模型能据此改正
    【边界】空命令 → 拒绝（"empty command"）
    【工程联系】一行命令里可能有好几个程序（管道、分号、命令替换），所以要检查**每一个**，而不只是第一个词（讲义 §4.5）。
    """
    a = analyze_command(cmd)
    if a.errors:
        return False, "cannot parse command: " + "; ".join(a.errors)
    bad = sorted(set(a.features) & set(rejected_features))
    if bad:
        return False, "shell features not allowed: " + ", ".join(bad) + \
            ("" if "heredoc" not in bad else " (write multi-line scripts as python3 -c '...')")
    if not a.programs:
        return False, "empty command"
    for p in a.programs:
        if "/" in p:
            return False, f"program must be a bare name from the allowlist, got {p!r}"
        if p not in allowed:
            return False, f"program {p!r} is not on the allowlist ({', '.join(allowed)})"
    return True, "ok"


# ============================================================================ 3. 环境变量与路径
def scrubbed_env(base=None, keep=("PATH", "LANG", "LC_ALL", "TZ"), extra=None):
    """
    造一个只含少数安全变量的环境变量字典，给子进程用。

    【思路】子进程默认继承父进程的**全部**环境变量——包括 ANTHROPIC_API_KEY 这类密钥。
        模型写的命令只要 `env` 或 `echo $ANTHROPIC_API_KEY`，密钥就会进入模型的上下文（甚至被发出去）。
        白名单（只保留需要的）比黑名单（删掉看起来像密钥的）更安全：你想不到的名字也不会漏。
    【参数】base：从哪个字典里挑（默认 os.environ）；keep：要保留的变量名；extra：额外添加的变量
    【返回】dict
    """
    base = os.environ if base is None else base
    env = {k: base[k] for k in keep if k in base}
    env.update(extra or {})
    return env


def looks_secret(name):
    """变量名看起来像不像密钥（只用于演示"黑名单会漏掉什么"）。"""
    return any(h in name.upper() for h in SECRET_HINTS)


def naive_inside(path, workspace):
    """
    **错误示范**：用字符串前缀判断路径是否在工作区里。

    【为什么错】(1) "/tmp/ws2/x".startswith("/tmp/ws") 为真，但它根本不在 /tmp/ws 里；
        (2) "/tmp/ws/../secret" 字面上以 /tmp/ws 开头，实际指向外面；(3) 工作区里的符号链接可以指向任何地方。
    """
    full = path if os.path.isabs(path) else os.path.join(workspace, path)
    return full.startswith(workspace)


def resolve_in_workspace(path, workspace):
    """
    把（模型给的）路径解析成真实的绝对路径，并确认它在工作区之内。

    【思路】os.path.realpath 会：把相对路径接到当前目录后面、消掉 "." 和 ".."、**展开所有符号链接**。
        对工作区本身也做一次 realpath（在 macOS 上 /var 其实是 /private/var 的符号链接），
        然后用 os.path.commonpath 比较"两条路径的公共前缀是不是工作区本身"——按路径的组成部分比较，
        不是按字符比较，所以 /tmp/ws2 不会被当成 /tmp/ws 的一部分。
    【参数】path：绝对或相对路径（相对路径相对于工作区）；workspace：工作区目录
    【返回】真实的绝对路径（str）
    【边界】逃出工作区 → 抛 PermissionError（带原因）。路径不存在也可以解析（realpath 不要求存在）。
    """
    ws = os.path.realpath(workspace)
    full = path if os.path.isabs(path) else os.path.join(ws, path)
    real = os.path.realpath(full)
    if os.path.commonpath([real, ws]) != ws:
        raise PermissionError(f"{path!r} resolves to {real!r}, which is outside the workspace")
    return real


# ============================================================================ 4. 输出太长时怎么办
def truncate_head_tail(text, max_chars, head_frac=0.5):
    """
    观察策略 A：只保留开头和结尾，中间换成一句说明。（OpenAI Codex CLI 的做法与此类似。）

    【参数】text；max_chars：最多给模型看多少字符；head_frac：开头占多少比例
    【返回】(shown, truncated: bool)
    【边界】text 不超过 max_chars 时原样返回。
    """
    if len(text) <= max_chars:
        return text, False
    h = int(max_chars * head_frac)
    t = max_chars - h
    note = f"\n... [{len(text) - h - t} characters omitted] ...\n"
    return text[:h] + note + text[len(text) - t:], True


def spill_to_file(text, max_chars, directory, name):
    """
    观察策略 B：完整输出写进一个文件，给模型看开头 + 文件路径，需要时它可以自己 grep / 分页读。
    （OpenHands、Gemini CLI、Claude Code 的做法与此类似——见原课 HW1 的说明。）

    【参数】text；max_chars；directory：存文件的目录（应在工作区里，模型才读得到）；name：文件名
    【返回】(shown, path 或 None)
    """
    if len(text) <= max_chars:
        return text, None
    os.makedirs(directory, exist_ok=True)
    path = os.path.join(directory, name)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)
    head = text[:max_chars]
    shown = head + f"\n... [output truncated: {len(text)} characters in total; full output saved to {path}]"
    return shown, path


# ============================================================================ 5. 把它们组合成一个终端工具
@dataclass
class TerminalResult:
    """
    终端工具的一次调用结果（字段和原课 HW1 的 TerminalResult 同名的那几个含义相同）。

    【字段】stdout / stderr / exit_code / timed_out：同 ProcResult；
        rejected / reason：命令被检查拒绝了吗、为什么；
        shown：**模型实际看到的**文字（经过输出策略处理）；full_chars：完整输出有多少字符；
        saved_to：策略 B 存下的文件路径
    """
    command: str
    stdout: str = ""
    stderr: str = ""
    exit_code: int = None
    timed_out: bool = False
    rejected: bool = False
    reason: str = ""
    shown: str = ""
    full_chars: int = 0
    saved_to: str = None

    @property
    def status(self):
        """ok：成功退出；tool_error：拒绝、超时或非零退出码（和 HW1 的约定一样）。"""
        return "ok" if (not self.rejected and not self.timed_out and self.exit_code == 0) else "tool_error"


class Terminal:
    """
    给智能体用的迷你终端：白名单检查 → 在工作区里、用干净的环境变量执行 → 超时杀整组 → 按策略截断输出。

    【它挡住了什么、没挡住什么】（第 05 号脚本逐条演示）
        挡住：不在白名单里的程序、命令替换 / here-doc 这类会"藏程序"的语法、死循环（超时）、
              读取父进程的密钥（干净的环境变量）、几十万字符的输出撑爆上下文（输出策略）
        没挡住：白名单里的 python3 本身可以做任何事——读写工作区外的文件、联网……
              这一层必须交给操作系统的沙箱（前置讲 P4、第 14 讲）
    """

    def __init__(self, workspace, allowed=DEFAULT_ALLOWED, timeout=5.0, max_chars=4000, policy="head_tail",
                 rejected_features=DEFAULT_REJECTED_FEATURES):
        """
        【参数】workspace：命令的工作目录；allowed：程序白名单；timeout：每条命令最多几秒；
            max_chars：模型最多看到多少字符；policy："pass"（原样）/"head_tail"/"spill"；
            rejected_features：禁止的 Shell 特性
        """
        self.workspace = os.path.realpath(workspace)
        self.allowed = tuple(allowed)
        self.timeout = timeout
        self.max_chars = max_chars
        self.policy = policy
        self.rejected_features = tuple(rejected_features)
        self.calls = 0

    def run(self, cmd):
        """
        执行一条命令。【返回】TerminalResult。【边界】永远不抛异常：拒绝、超时、失败都写在结果里。
        """
        self.calls += 1
        ok, reason = check_allowlist(cmd, self.allowed, self.rejected_features)
        if not ok:
            return TerminalResult(cmd, rejected=True, reason=reason, shown=f"[rejected] {reason}")
        r = run_shell(cmd, cwd=self.workspace, env=scrubbed_env(), timeout=self.timeout)
        res = TerminalResult(cmd, r.stdout, r.stderr, r.exit_code, r.timed_out)
        body = r.stdout
        if r.stderr:                                  # stderr 也要给模型看：错误原因常常只写在这里
            body += ("\n" if body and not body.endswith("\n") else "") + "[stderr]\n" + r.stderr
        res.full_chars = len(body)
        if self.policy == "head_tail":
            body, _ = truncate_head_tail(body, self.max_chars)
        elif self.policy == "spill":
            body, res.saved_to = spill_to_file(body, self.max_chars, os.path.join(self.workspace, ".outputs"),
                                               f"call_{self.calls:03d}.txt")
        status = f"[exit code {r.exit_code}]" if not r.timed_out else f"[timed out after {self.timeout:g} s]"
        res.shown = body + ("\n" if body and not body.endswith("\n") else "") + status
        return res


if __name__ == "__main__":
    for c in ["grep -c urgent emails.jsonl", "cat a.txt | sort | uniq -c > out.txt",
              "echo $(python3 -c 'print(42)')", "python3 - <<EOF\nprint(1)\nEOF", "ls; sleep 1",
              'echo "unclosed']:
        a = analyze_command(c)
        print(f"{c!r:45}  programs={a.programs}  features={sorted(a.features)}  errors={a.errors}")
        print(f"{'':45}  allowlist → {check_allowlist(c)}")
    with tempfile.TemporaryDirectory() as ws:
        term = Terminal(ws, timeout=1.0)
        for c in ["echo hello > a.txt && cat a.txt", "python3 -c 'import sys; sys.exit(3)'", "sleep 5"]:
            r = term.run(c)
            print(f"\n$ {c}\n{r.shown}\n→ status={r.status}")
