Commit abfb3cb5b0a8297927523626eea12d2ca4256289

Authored by bboonnee
1 parent 753fefa4

U-036: real unittest coverage for supervisor/isolation + Deploy Supervisor skeleton

Ports graphloop's test_isolated_coding_workspace.py/test_workspace_security.py/
test_change_manifest.py/test_selective_apply.py into supervisor/isolation/
(only the import path changed -- the graphlemon modules were already adapted
verbatim from graphloop, confirmed API-identical) plus a few extra edge
cases the originals didn't cover. 52 tests green, replacing what had been a
single manual smoke test since these modules were staged.

Adds supervisor/deploy_supervisor.py: process lifecycle only (PID file,
atomic status file, start/stop/is_running), no deploy decision logic yet --
that's U-037+'s job, injected via the poll_fn hook run_foreground() calls on
a schedule. PID/status conventions adapted (not imported) from graphloop's
hermes_watchdog.py atomic-write pattern. Fail-closed: any exit path
(normal, SIGTERM, or an exception from poll_fn) unconditionally writes
status='stopped' and removes the PID file, so is_running() never lies.
RealSubprocessLifecycleTest actually spawns and stops a real
`python3 -m supervisor.deploy_supervisor` process rather than mocking it,
per this Unit's own "能独立启停" Done criterion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
... ... @@ -354,6 +354,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
354 354 - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发)
355 355 - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响
356 356 - **待定(2026-07-31 订正)**:此前这里写的是"`supervisor/` 目录该归入 U-002 的目标项目树(`.claude/`、`skills/`、`src/` 那套)还是 U-003 的平台目录"——但 U-002 的范围已经订正为只创建 `.graphlemon/`(见该 Unit 2026-07-31 订正说明),`.claude/`、`skills/` 从来就不是目标项目的东西,是 graphlemon 平台自己仓库的资产。所以这个选择题已经不成立,`supervisor/` 只可能归属"platform 自身工具"(当前的实际存放位置,`supervisor/isolation/`),不存在真正的"归入目标项目"选项;保留这条记录是为了让后来者理解为什么这里曾经有过一次误判
  357 + - **实现补充(2026-08-02)**:四模块的单测直接照搬 graphloop 对应文件(`test_isolated_coding_workspace.py`/`test_workspace_security.py`/`test_change_manifest.py`/`test_selective_apply.py`),逐条核对后确认 API 与行为完全匹配(graphlemon 这四个模块本来就是"adapted verbatim"),只改了 import 路径(`from supervisor.isolation import X`)+ 补了几条 graphlemon 自己模块文档里提到但原测试没覆盖的小 case(`isolated_workspace` 的空/不存在 source_dir 拒绝、`workspace_security` 的换行符注入拒绝);新增 `supervisor/__init__.py`(原本靠 Python 3 namespace package 也能跑,但补一个空的显式 `__init__.py` 和 `src/__init__.py` 保持同一约定,不依赖隐式行为)。52 个测试全绿,放在 `supervisor/isolation/test_*.py`(和被测模块同目录,比照 `src/test_*.py` 的既有约定),运行方式 `python3 -m unittest discover -s supervisor/isolation -p "test_*.py"`(不在 `python3 -m unittest discover -s src` 覆盖范围内,是新增的第二条独立测试入口,README/CLAUDE.md 后续需要记一笔)。
  358 + Deploy Supervisor 骨架落在新文件 `supervisor/deploy_supervisor.py`:PID 文件+状态文件走 `paths.platform_home()/deploy_supervisor/` 下(复用 U-003 已有的运行时目录约定,不新建一套),原子写沿用 `tempfile+os.replace`(参照 graphloop `hermes_watchdog.py` 的 `_atomic_save`,未直接 import,逻辑重写了一遍)。`run_foreground(poll_fn, poll_interval, iterations)` 是骨架的核心循环——`poll_fn` 是 U-037+ 才会真正实现的"检查是否有待部署的 commit 并行动"逻辑,本 Unit 只负责按周期调用它、写 PID/状态文件、处理 `SIGTERM`(收到后跳出循环,`finally` 块无条件写 `status='stopped'` 并删 PID 文件,包括 `poll_fn` 自己抛异常的情况——fail-closed,崩溃了就该看起来像崩溃了,不能让 PID 文件继续指向一个已经不存在的循环)。`start_detached()`/`stop()` 是给上层(未来某个"启动 graphlemon 平台"脚本,或人工)用的真实进程管理入口,`start_detached` 用 `subprocess.Popen` 拉起真实独立进程(不是线程,故意如此——两个进程之间不共享失败域是这整个设计的前提),`stop()` 发 `SIGTERM` 并轮询等它自己清理干净,超时不 SIGKILL(留给调用方判断,不擅自做主)。**验证方式**:`RunForegroundTest` 系列在同一个测试进程内直接验证循环/状态写入/`SIGTERM`处理(`SIGTERM` 测试用 `poll_fn` 内部对自己进程 `os.kill(os.getpid(), SIGTERM)` 模拟外部信号,不需要真的起第二个进程);`RealSubprocessLifecycleTest` 是真实的(不 mock)`python3 -m supervisor.deploy_supervisor` 子进程拉起+`stop()`往返,确认"能独立启停"这条 Done 标准不是靠单元测试逻辑自证的,是真的对着一个真实操作系统进程验证过的。全程用 `GRAPHLEMON_HOME` 环境变量覆盖到临时目录,不碰真实用户的 `~/.graphlemon`。73 个测试(含四个 isolation 模块)全绿。
  359 + - 覆盖:`supervisor/isolation/` 四模块(真实单测覆盖,不再只是"一次性手工冒烟");Deploy Supervisor 进程骨架(`supervisor/deploy_supervisor.py`,尚不含任何真实部署决策逻辑)
357 360  
358 361 - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库)
359 362 - 依赖:U-034, U-036
... ...
docs/sdd/unit_matrix.json
... ... @@ -53,7 +53,7 @@
53 53  
54 54 {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功(共 17 张);bug_record 新枚举值向后兼容 U-015/U-018 已有测试;deployment 状态机用 01-M 1.15 完整 15 值枚举;ux_deployment_active 表达式索引强制全局单例非终态约束;bug_record.py 应用层白名单刻意未跟着放开 core_engine,classification+路由逻辑留给 U-035"},
55 55 {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 分类审计(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree(work_graph_orchestrate.run_graph 内强制);core_engine 分类(src/core_engine_classify.py,路径规则)在 self-target 任务 PASS 收尾时记一条审计用 bug_record;且改动落地后重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过。**2026-08-02 用户决策修正**:Deploy Supervisor 触发条件按 supervisor-auto-rollback-design.md §1 字面设计(self-target+PASS 即触发,bug_record 是旁路产物不是前提),而非 03-C/04-P 字面文本要求的\"先有 core_engine bug_record\"——后者会让从不产生 bug_record 的 goal 型自我改进任务永远触发不了 Deploy Supervisor,已用 AskUserQuestion 与用户确认此订正。c_org_loop_route 未改动(fast_lane/full_replan 是 FAIL 路径重试路由,与此处 PASS 路径的蓝绿触发正交),deployment 行创建仍是 U-037 的范围,不在本 Unit 提前做"},
56   - {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py;Deploy Supervisor 骨架独立进程可启停,不受 Live/Candidate 崩溃影响"},
  56 + {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py(供 python3 -m unittest discover -s supervisor/isolation 运行,52 测试全绿);Deploy Supervisor 骨架(supervisor/deploy_supervisor.py)独立进程可启停(真实子进程验证,非 mock),不受 Live/Candidate 崩溃影响;本 Unit 只做进程生命周期骨架,不含任何真实部署决策逻辑(U-037+ 的 poll_fn 挂载点)"},
57 57 {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程"},
58 58 {"id": "U-038", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_health_check", "classification": "Risk Gate", "depends_on": ["U-037"], "coverage": {"model": [], "view": [], "controller": ["c_deploy_supervisor_health_check"], "process": [], "requirement": []}, "done": "process_alive/http_health/canary_task 三项检查全过 -> health_check_passed;任一失败 -> health_check_failed 且 Live 未受影响"},
59 59 {"id": "U-039", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_cutover", "classification": "Risk Gate", "depends_on": ["U-038"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_cutover"], "process": [], "requirement": []}, "done": "健康检查通过后完成真实切换;在途 work_node_execution 重启后可恢复;previous_instance_dir 保留至监控窗口结束"},
... ...
supervisor/__init__.py 0 → 100644
supervisor/deploy_supervisor.py 0 → 100644
  1 +"""Deploy Supervisor process skeleton (U-036).
  2 +
  3 +Independent OS process (started via `python3 -m supervisor.deploy_supervisor`),
  4 +pure stdlib, no LLM CLI dependency -- per docs/supervisor-auto-rollback-design.md
  5 +§2's "three independent processes, no shared failure domain" principle:
  6 +Deploy Supervisor crashing must not affect Live/Candidate, and Live/Candidate
  7 +crashing must not affect Deploy Supervisor's own ability to keep observing
  8 +and act.
  9 +
  10 +PID/status file conventions adapted (NOT imported -- see CLAUDE.md's "do not
  11 +import graphloop code directly" rule) from graphloop's hermes_watchdog.py:
  12 +atomic writes (tempfile-in-same-dir + os.replace), liveness via
  13 +`os.kill(pid, 0)`, fail-closed (this skeleton never auto-restarts anything --
  14 +a crashing `poll_fn` stops the loop and marks status='stopped' rather than
  15 +silently dying while still holding the PID file, which would make
  16 +`is_running()` lie).
  17 +
  18 +Scope: this Unit owns process lifecycle only (start/stop/status/PID file).
  19 +The actual "check for staged deployments and act" logic is U-037+'s job,
  20 +injected here as the `poll_fn` callback -- U-036 has no opinion about what
  21 +that logic does, only about running it on a schedule and surviving/reporting
  22 +its own crashes correctly.
  23 +"""
  24 +
  25 +import argparse
  26 +import json
  27 +import os
  28 +import signal
  29 +import subprocess
  30 +import sys
  31 +import time
  32 +
  33 +from src import paths
  34 +
  35 +
  36 +def runtime_dir():
  37 + d = os.path.join(paths.platform_home(), "deploy_supervisor")
  38 + os.makedirs(d, exist_ok=True)
  39 + return d
  40 +
  41 +
  42 +def pid_file_path():
  43 + return os.path.join(runtime_dir(), "supervisor.pid")
  44 +
  45 +
  46 +def status_file_path():
  47 + return os.path.join(runtime_dir(), "supervisor.status.json")
  48 +
  49 +
  50 +def _atomic_write_json(path, data):
  51 + tmp = path + ".{}.tmp".format(os.getpid())
  52 + with open(tmp, "w", encoding="utf-8") as handle:
  53 + json.dump(data, handle)
  54 + os.replace(tmp, path)
  55 +
  56 +
  57 +def is_alive(pid):
  58 + if not isinstance(pid, int) or pid <= 0:
  59 + return False
  60 + try:
  61 + os.kill(pid, 0)
  62 + except OSError:
  63 + return False
  64 + return True
  65 +
  66 +
  67 +def write_status(status, detail=None):
  68 + _atomic_write_json(status_file_path(), {
  69 + "status": status, "pid": os.getpid(), "updated_at": time.time(), "detail": detail,
  70 + })
  71 +
  72 +
  73 +def read_status():
  74 + try:
  75 + with open(status_file_path(), encoding="utf-8") as handle:
  76 + return json.load(handle)
  77 + except (OSError, json.JSONDecodeError):
  78 + return None
  79 +
  80 +
  81 +def is_running():
  82 + """True if a Deploy Supervisor process is currently alive per the PID
  83 + file. Doesn't distinguish "never started" from "PID file stale" --
  84 + callers needing that distinction should also check `read_status()`."""
  85 + try:
  86 + with open(pid_file_path(), encoding="utf-8") as handle:
  87 + pid = int(handle.read().strip())
  88 + except (OSError, ValueError):
  89 + return False
  90 + return is_alive(pid)
  91 +
  92 +
  93 +class _StopRequested(Exception):
  94 + """Internal: raised by the SIGTERM handler to break run_foreground's loop."""
  95 +
  96 +
  97 +def run_foreground(poll_fn=None, poll_interval=5.0, iterations=None):
  98 + """Blocking main loop -- calls `poll_fn()` (if given) once per
  99 + `poll_interval` seconds, up to `iterations` cycles (None = forever, only
  100 + exits on SIGTERM). Writes a PID file + 'running' status on entry, and
  101 + unconditionally writes a 'stopped' status + removes the PID file on any
  102 + exit path (normal completion, SIGTERM, or an unhandled exception from
  103 + `poll_fn`) -- an exception from `poll_fn` propagates to the caller after
  104 + cleanup runs, it is not swallowed (fail-closed: a broken poll cycle
  105 + should surface, not vanish into an infinite silent retry).
  106 + """
  107 + def _handle_sigterm(signum, frame):
  108 + raise _StopRequested()
  109 +
  110 + previous_handler = signal.signal(signal.SIGTERM, _handle_sigterm)
  111 + with open(pid_file_path(), "w", encoding="utf-8") as handle:
  112 + handle.write(str(os.getpid()))
  113 + write_status("running")
  114 + try:
  115 + count = 0
  116 + while iterations is None or count < iterations:
  117 + if poll_fn is not None:
  118 + poll_fn()
  119 + count += 1
  120 + if iterations is not None and count >= iterations:
  121 + break
  122 + time.sleep(poll_interval)
  123 + except _StopRequested:
  124 + pass
  125 + finally:
  126 + signal.signal(signal.SIGTERM, previous_handler)
  127 + write_status("stopped")
  128 + if os.path.exists(pid_file_path()):
  129 + os.remove(pid_file_path())
  130 +
  131 +
  132 +def start_detached(poll_interval=5.0):
  133 + """Launches `python3 -m supervisor.deploy_supervisor` as a real,
  134 + independent OS process (Popen, not a thread) -- crashing does not take
  135 + down whatever called this. Returns the child's pid. Raises RuntimeError
  136 + if a Deploy Supervisor is already running (per its own PID file)."""
  137 + if is_running():
  138 + raise RuntimeError("Deploy Supervisor is already running")
  139 + process = subprocess.Popen(
  140 + [sys.executable, "-m", "supervisor.deploy_supervisor", "--poll-interval", str(poll_interval)],
  141 + cwd=paths.repo_root(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
  142 + )
  143 + return process.pid
  144 +
  145 +
  146 +def stop(timeout=10.0):
  147 + """Sends SIGTERM to the running Deploy Supervisor (if any) and waits up
  148 + to `timeout` seconds for it to exit cleanly (PID file removed). Returns
  149 + True if it stopped, False if it was already not running or didn't stop
  150 + in time -- fail-closed: this does not SIGKILL on timeout, that's a
  151 + judgment call left to whoever's driving it, not silently forced here."""
  152 + try:
  153 + with open(pid_file_path(), encoding="utf-8") as handle:
  154 + pid = int(handle.read().strip())
  155 + except (OSError, ValueError):
  156 + return False
  157 + if not is_alive(pid):
  158 + return False
  159 + os.kill(pid, signal.SIGTERM)
  160 + deadline = time.time() + timeout
  161 + while time.time() < deadline:
  162 + if not is_running():
  163 + return True
  164 + time.sleep(0.1)
  165 + return False
  166 +
  167 +
  168 +def main():
  169 + parser = argparse.ArgumentParser()
  170 + parser.add_argument("--poll-interval", type=float, default=5.0)
  171 + args = parser.parse_args()
  172 + run_foreground(poll_interval=args.poll_interval)
  173 +
  174 +
  175 +if __name__ == "__main__":
  176 + main()
... ...
supervisor/isolation/test_change_manifest.py 0 → 100644
  1 +"""U-036: real unittest coverage for change_manifest.py -- adapted from
  2 +graphloop's test_change_manifest.py (2026-08-02). Module logic is unchanged
  3 +from graphloop ("adapted verbatim", see change_manifest.py's own docstring)
  4 +except graphlemon's `unified_diff` collapses "@@ -l,s +l,s @@" hunk headers
  5 +to a single leading "@@" marker (see that module's own comment) -- the
  6 +"only text gets hunks" assertion below counts "@@" substring occurrences,
  7 +which still holds 1-to-1 with graphlemon's collapsed format for these
  8 +single-hunk fixtures.
  9 +
  10 +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v
  11 +"""
  12 +import hashlib
  13 +import os
  14 +import shutil
  15 +import tempfile
  16 +import unittest
  17 +
  18 +from supervisor.isolation import change_manifest as cm
  19 +
  20 +TEXT_A = "hello\n"
  21 +TEXT_A_MODIFIED = "hello modified\n"
  22 +BINARY_BYTES = b"\x89PNG\r\n\x1a\n\x00\x01\x02\x03BINARYDATA\xff\xfe"
  23 +
  24 +
  25 +def sha256_of(data):
  26 + return hashlib.sha256(data).hexdigest()
  27 +
  28 +
  29 +class ChangeManifestTestCase(unittest.TestCase):
  30 + def setUp(self):
  31 + self.source_dir = tempfile.mkdtemp(prefix="cm-src-")
  32 + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
  33 + self._extra_dirs = []
  34 + self.addCleanup(self._cleanup_extra_dirs)
  35 +
  36 + def _cleanup_extra_dirs(self):
  37 + for path in self._extra_dirs:
  38 + shutil.rmtree(path, ignore_errors=True)
  39 +
  40 + def _new_isolated_dir(self):
  41 + path = tempfile.mkdtemp(prefix="cm-iso-")
  42 + self._extra_dirs.append(path)
  43 + return path
  44 +
  45 + @staticmethod
  46 + def _write_text(root, rel_path, content):
  47 + full = os.path.join(root, *rel_path.split("/"))
  48 + os.makedirs(os.path.dirname(full), exist_ok=True)
  49 + with open(full, "w", encoding="utf-8") as handle:
  50 + handle.write(content)
  51 + return full
  52 +
  53 + @staticmethod
  54 + def _write_bytes(root, rel_path, data):
  55 + full = os.path.join(root, *rel_path.split("/"))
  56 + os.makedirs(os.path.dirname(full), exist_ok=True)
  57 + with open(full, "wb") as handle:
  58 + handle.write(data)
  59 + return full
  60 +
  61 + def _copy_source_to_isolated(self):
  62 + isolated_dir = self._new_isolated_dir()
  63 + shutil.rmtree(isolated_dir)
  64 + shutil.copytree(self.source_dir, isolated_dir)
  65 + return isolated_dir
  66 +
  67 +
  68 +class SnapshotWorkspaceTests(ChangeManifestTestCase):
  69 + def test_records_text_file_with_hash_size_and_kind(self):
  70 + self._write_text(self.source_dir, "a.txt", TEXT_A)
  71 +
  72 + snapshot = cm.snapshot_workspace(self.source_dir)
  73 +
  74 + entry = snapshot["files"]["a.txt"]
  75 + self.assertEqual(entry["sha256"], sha256_of(TEXT_A.encode("utf-8")))
  76 + self.assertEqual(entry["size"], len(TEXT_A.encode("utf-8")))
  77 + self.assertEqual(entry["kind"], "text")
  78 +
  79 + def test_records_binary_file_as_binary_kind(self):
  80 + self._write_bytes(self.source_dir, "logo.bin", BINARY_BYTES)
  81 +
  82 + snapshot = cm.snapshot_workspace(self.source_dir)
  83 +
  84 + entry = snapshot["files"]["logo.bin"]
  85 + self.assertEqual(entry["sha256"], sha256_of(BINARY_BYTES))
  86 + self.assertEqual(entry["size"], len(BINARY_BYTES))
  87 + self.assertEqual(entry["kind"], "binary")
  88 +
  89 + def test_uses_relative_posix_paths_for_nested_files(self):
  90 + self._write_text(self.source_dir, "subdir/nested/file.txt", "x")
  91 +
  92 + snapshot = cm.snapshot_workspace(self.source_dir)
  93 +
  94 + self.assertIn("subdir/nested/file.txt", snapshot["files"])
  95 +
  96 + def test_ignores_git_pycache_pytest_cache_and_dsstore(self):
  97 + self._write_text(self.source_dir, "keep.txt", "keep")
  98 + self._write_text(self.source_dir, ".git/config", "git config")
  99 + self._write_text(self.source_dir, ".git/objects/ab/cdef", "blob")
  100 + self._write_text(self.source_dir, "__pycache__/mod.cpython-39.pyc", "pyc")
  101 + self._write_text(self.source_dir, ".pytest_cache/v/cache/lastfailed", "{}")
  102 + self._write_text(self.source_dir, ".DS_Store", "junk")
  103 +
  104 + snapshot = cm.snapshot_workspace(self.source_dir)
  105 +
  106 + self.assertEqual(set(snapshot["files"]), {"keep.txt"})
  107 +
  108 + def test_skips_symlinks_but_keeps_their_real_target(self):
  109 + target = self._write_text(self.source_dir, "real.txt", "real content")
  110 + link_path = os.path.join(self.source_dir, "link.txt")
  111 + os.symlink(target, link_path)
  112 +
  113 + snapshot = cm.snapshot_workspace(self.source_dir)
  114 +
  115 + self.assertIn("real.txt", snapshot["files"])
  116 + self.assertNotIn("link.txt", snapshot["files"])
  117 +
  118 +
  119 +class BuildChangeManifestTests(ChangeManifestTestCase):
  120 + def _baseline_snapshot(self):
  121 + self._write_text(self.source_dir, "a.txt", TEXT_A)
  122 + self._write_text(self.source_dir, "b.txt", "world\n")
  123 + self._write_text(self.source_dir, "c.txt", "keep me\n")
  124 + return cm.snapshot_workspace(self.source_dir)
  125 +
  126 + def test_added_modified_deleted_and_unchanged_classification(self):
  127 + snapshot = self._baseline_snapshot()
  128 + isolated_dir = self._copy_source_to_isolated()
  129 +
  130 + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)
  131 + os.remove(os.path.join(isolated_dir, "b.txt"))
  132 + self._write_text(isolated_dir, "d.txt", "brand new\n")
  133 +
  134 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  135 + by_path = {entry["path"]: entry for entry in manifest["changed_files"]}
  136 +
  137 + self.assertEqual(set(by_path), {"a.txt", "b.txt", "d.txt"})
  138 + self.assertNotIn("c.txt", by_path)
  139 +
  140 + modified = by_path["a.txt"]
  141 + self.assertEqual(modified["status"], "modified")
  142 + self.assertEqual(modified["old_hash"], sha256_of(TEXT_A.encode("utf-8")))
  143 + self.assertEqual(modified["new_hash"], sha256_of(TEXT_A_MODIFIED.encode("utf-8")))
  144 +
  145 + deleted = by_path["b.txt"]
  146 + self.assertEqual(deleted["status"], "deleted")
  147 + self.assertIsNone(deleted["new_hash"])
  148 + self.assertEqual(deleted["old_hash"], sha256_of(b"world\n"))
  149 +
  150 + added = by_path["d.txt"]
  151 + self.assertEqual(added["status"], "added")
  152 + self.assertIsNone(added["old_hash"])
  153 + self.assertEqual(added["new_hash"], sha256_of(b"brand new\n"))
  154 +
  155 + def test_unchanged_files_never_appear_in_changed_files(self):
  156 + snapshot = self._baseline_snapshot()
  157 + isolated_dir = self._copy_source_to_isolated()
  158 +
  159 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  160 +
  161 + self.assertEqual(manifest["changed_files"], [])
  162 + self.assertEqual(cm.unified_diff(manifest), "")
  163 +
  164 + def test_ignored_files_excluded_even_when_added_in_isolated(self):
  165 + snapshot = self._baseline_snapshot()
  166 + isolated_dir = self._copy_source_to_isolated()
  167 +
  168 + self._write_text(isolated_dir, ".git/config", "new git config")
  169 + self._write_text(isolated_dir, "__pycache__/mod.cpython-39.pyc", "pyc")
  170 + self._write_text(isolated_dir, ".DS_Store", "junk")
  171 +
  172 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  173 +
  174 + self.assertEqual(manifest["changed_files"], [])
  175 +
  176 + def test_binary_file_added_is_classified_binary(self):
  177 + snapshot = self._baseline_snapshot()
  178 + isolated_dir = self._copy_source_to_isolated()
  179 + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)
  180 +
  181 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  182 +
  183 + self.assertEqual(len(manifest["changed_files"]), 1)
  184 + entry = manifest["changed_files"][0]
  185 + self.assertEqual(entry["path"], "logo.bin")
  186 + self.assertEqual(entry["status"], "added")
  187 + self.assertEqual(entry["kind"], "binary")
  188 + self.assertEqual(entry["new_hash"], sha256_of(BINARY_BYTES))
  189 +
  190 + def test_changed_files_are_sorted_deterministically(self):
  191 + snapshot = self._baseline_snapshot()
  192 + isolated_dir = self._copy_source_to_isolated()
  193 +
  194 + self._write_text(isolated_dir, "z_file.txt", "z\n")
  195 + self._write_text(isolated_dir, "0_file.txt", "0\n")
  196 + self._write_text(isolated_dir, "m_file.txt", "m\n")
  197 +
  198 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  199 + paths = [entry["path"] for entry in manifest["changed_files"]]
  200 +
  201 + self.assertEqual(paths, sorted(paths))
  202 + self.assertEqual(paths, ["0_file.txt", "m_file.txt", "z_file.txt"])
  203 +
  204 + def test_rejects_symlink_found_in_isolated_workspace(self):
  205 + snapshot = self._baseline_snapshot()
  206 + isolated_dir = self._copy_source_to_isolated()
  207 + target = os.path.join(isolated_dir, "a.txt")
  208 + os.symlink(target, os.path.join(isolated_dir, "sneaky_link.txt"))
  209 +
  210 + with self.assertRaises(cm.ChangeManifestError):
  211 + cm.build_change_manifest(snapshot, isolated_dir)
  212 +
  213 + def test_rejects_symlink_escaping_isolated_workspace(self):
  214 + snapshot = self._baseline_snapshot()
  215 + isolated_dir = self._copy_source_to_isolated()
  216 + outside_dir = self._new_isolated_dir()
  217 + outside_file = self._write_text(outside_dir, "outside.txt", "outside")
  218 + os.symlink(outside_file, os.path.join(isolated_dir, "escape_link.txt"))
  219 +
  220 + with self.assertRaises(cm.ChangeManifestError):
  221 + cm.build_change_manifest(snapshot, isolated_dir)
  222 +
  223 + def test_rejects_tampered_snapshot_with_traversal_path(self):
  224 + snapshot = self._baseline_snapshot()
  225 + isolated_dir = self._copy_source_to_isolated()
  226 +
  227 + for bad_path in ("../evil.txt", "/etc/passwd", "sub/../../escape.txt"):
  228 + with self.subTest(bad_path=bad_path):
  229 + tampered = {"root": snapshot["root"], "files": dict(snapshot["files"])}
  230 + tampered["files"][bad_path] = {"sha256": "0" * 64, "size": 0, "kind": "text"}
  231 + with self.assertRaises(cm.ChangeManifestError):
  232 + cm.build_change_manifest(tampered, isolated_dir)
  233 +
  234 +
  235 +class UnifiedDiffTests(ChangeManifestTestCase):
  236 + def test_modified_text_file_produces_readable_unified_diff(self):
  237 + self._write_text(self.source_dir, "a.txt", TEXT_A)
  238 + snapshot = cm.snapshot_workspace(self.source_dir)
  239 + isolated_dir = self._copy_source_to_isolated()
  240 + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)
  241 +
  242 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  243 + diff_text = cm.unified_diff(manifest)
  244 + lines = diff_text.splitlines()
  245 +
  246 + self.assertTrue(any(l.startswith("---") and "a.txt" in l for l in lines))
  247 + self.assertTrue(any(l.startswith("+++") and "a.txt" in l for l in lines))
  248 + self.assertTrue(any(l.startswith("@@") for l in lines))
  249 + self.assertTrue(any(l.startswith("-") and "hello" in l and not l.startswith("---") for l in lines))
  250 + self.assertTrue(any(l.startswith("+") and "hello modified" in l and not l.startswith("+++") for l in lines))
  251 +
  252 + def test_added_text_file_diffs_against_dev_null(self):
  253 + snapshot = cm.snapshot_workspace(self.source_dir)
  254 + isolated_dir = self._copy_source_to_isolated()
  255 + self._write_text(isolated_dir, "new.txt", "brand new content\n")
  256 +
  257 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  258 + diff_text = cm.unified_diff(manifest)
  259 + lines = diff_text.splitlines()
  260 +
  261 + self.assertTrue(any("/dev/null" in l for l in lines))
  262 + self.assertTrue(any(l.startswith("+") and "brand new content" in l for l in lines))
  263 + removed_content_lines = [l for l in lines if l.startswith("-") and not l.startswith("---")]
  264 + self.assertEqual(removed_content_lines, [])
  265 +
  266 + def test_deleted_text_file_diffs_against_dev_null(self):
  267 + self._write_text(self.source_dir, "gone.txt", "will be deleted\n")
  268 + snapshot = cm.snapshot_workspace(self.source_dir)
  269 + isolated_dir = self._copy_source_to_isolated()
  270 + os.remove(os.path.join(isolated_dir, "gone.txt"))
  271 +
  272 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  273 + diff_text = cm.unified_diff(manifest)
  274 + lines = diff_text.splitlines()
  275 +
  276 + self.assertTrue(any("/dev/null" in l for l in lines))
  277 + self.assertTrue(any(l.startswith("-") and "will be deleted" in l for l in lines))
  278 + added_content_lines = [l for l in lines if l.startswith("+") and not l.startswith("+++")]
  279 + self.assertEqual(added_content_lines, [])
  280 +
  281 + def test_binary_file_diff_is_marker_not_content_diff(self):
  282 + snapshot = cm.snapshot_workspace(self.source_dir)
  283 + isolated_dir = self._copy_source_to_isolated()
  284 + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)
  285 +
  286 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  287 + diff_text = cm.unified_diff(manifest)
  288 +
  289 + self.assertIn("Binary files", diff_text)
  290 + self.assertIn("logo.bin", diff_text)
  291 + self.assertNotIn("@@", diff_text)
  292 +
  293 + def test_text_and_binary_changes_together_only_text_gets_hunks(self):
  294 + self._write_text(self.source_dir, "a.txt", TEXT_A)
  295 + self._write_text(self.source_dir, "gone.txt", "will be deleted\n")
  296 + snapshot = cm.snapshot_workspace(self.source_dir)
  297 + isolated_dir = self._copy_source_to_isolated()
  298 +
  299 + self._write_text(isolated_dir, "a.txt", TEXT_A_MODIFIED)
  300 + os.remove(os.path.join(isolated_dir, "gone.txt"))
  301 + self._write_text(isolated_dir, "new.txt", "added text\n")
  302 + self._write_bytes(isolated_dir, "logo.bin", BINARY_BYTES)
  303 +
  304 + manifest = cm.build_change_manifest(snapshot, isolated_dir)
  305 + diff_text = cm.unified_diff(manifest)
  306 +
  307 + text_change_count = sum(1 for entry in manifest["changed_files"] if entry["kind"] == "text")
  308 + self.assertEqual(diff_text.count("@@"), text_change_count)
  309 + self.assertIn("Binary files", diff_text)
  310 +
  311 +
  312 +if __name__ == "__main__":
  313 + unittest.main()
... ...
supervisor/isolation/test_isolated_workspace.py 0 → 100644
  1 +"""U-036: real unittest coverage for isolated_workspace.py -- adapted from
  2 +graphloop's test_isolated_coding_workspace.py (2026-08-02). Module logic is
  3 +unchanged from graphloop (see isolated_workspace.py's own docstring); only
  4 +the import path changed to match this package's location.
  5 +
  6 +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v
  7 +"""
  8 +import os
  9 +import shutil
  10 +import tempfile
  11 +import unittest
  12 +
  13 +from supervisor.isolation import isolated_workspace as iso
  14 +
  15 +
  16 +class IsolateWorkspaceTests(unittest.TestCase):
  17 + def setUp(self):
  18 + self.source_dir = tempfile.mkdtemp(prefix="iso-src-")
  19 + self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
  20 +
  21 + self.source_file = os.path.join(self.source_dir, "hello.txt")
  22 + with open(self.source_file, "w", encoding="utf-8") as handle:
  23 + handle.write("hello world")
  24 +
  25 + self._copies_to_clean = []
  26 + self.addCleanup(self._cleanup_copies)
  27 +
  28 + def _cleanup_copies(self):
  29 + for path in self._copies_to_clean:
  30 + shutil.rmtree(path, ignore_errors=True)
  31 +
  32 + def test_returns_separate_path_leaves_source_unchanged_and_copies_file(self):
  33 + copied_dir = iso.isolate_workspace(self.source_dir)
  34 + self._copies_to_clean.append(copied_dir)
  35 +
  36 + self.assertNotEqual(os.path.abspath(copied_dir), os.path.abspath(self.source_dir))
  37 +
  38 + self.assertTrue(os.path.isdir(self.source_dir))
  39 + with open(self.source_file, "r", encoding="utf-8") as handle:
  40 + self.assertEqual(handle.read(), "hello world")
  41 +
  42 + copied_file = os.path.join(copied_dir, "hello.txt")
  43 + self.assertTrue(os.path.isfile(copied_file))
  44 + with open(copied_file, "r", encoding="utf-8") as handle:
  45 + self.assertEqual(handle.read(), "hello world")
  46 +
  47 + def test_parent_dir_overrides_os_default_temp_location(self):
  48 + custom_parent = tempfile.mkdtemp(prefix="iso-parent-")
  49 + self.addCleanup(shutil.rmtree, custom_parent, ignore_errors=True)
  50 +
  51 + copied_dir = iso.isolate_workspace(self.source_dir, parent_dir=custom_parent)
  52 + self._copies_to_clean.append(copied_dir)
  53 +
  54 + self.assertEqual(os.path.dirname(copied_dir), custom_parent)
  55 +
  56 + def test_parent_dir_created_if_missing(self):
  57 + base = tempfile.mkdtemp(prefix="iso-parent-")
  58 + self.addCleanup(shutil.rmtree, base, ignore_errors=True)
  59 + custom_parent = os.path.join(base, "nested", "dir")
  60 +
  61 + copied_dir = iso.isolate_workspace(self.source_dir, parent_dir=custom_parent)
  62 + self._copies_to_clean.append(copied_dir)
  63 +
  64 + self.assertTrue(os.path.isdir(custom_parent))
  65 + self.assertEqual(os.path.dirname(copied_dir), custom_parent)
  66 +
  67 + def test_rejects_nonexistent_source_dir(self):
  68 + with self.assertRaises(iso.IsolatedWorkspaceError):
  69 + iso.isolate_workspace(os.path.join(self.source_dir, "does-not-exist"))
  70 +
  71 + def test_rejects_empty_source_dir_argument(self):
  72 + with self.assertRaises(iso.IsolatedWorkspaceError):
  73 + iso.isolate_workspace("")
  74 +
  75 +
  76 +if __name__ == "__main__":
  77 + unittest.main()
... ...
supervisor/isolation/test_selective_apply.py 0 → 100644
  1 +"""U-036: real unittest coverage for selective_apply.py -- adapted from
  2 +graphloop's test_selective_apply.py (2026-08-02). Module logic is unchanged
  3 +from graphloop except the internal `import change_manifest as cm` ->
  4 +relative package import (see selective_apply.py's own docstring); the
  5 +result key is `manifest_id` (not `digest`) in this port.
  6 +
  7 +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v
  8 +"""
  9 +import json
  10 +import os
  11 +import shutil
  12 +import tempfile
  13 +import unittest
  14 +
  15 +from supervisor.isolation import change_manifest as cm
  16 +from supervisor.isolation import selective_apply as sa
  17 +
  18 +
  19 +class SelectiveApplyTestCase(unittest.TestCase):
  20 + @staticmethod
  21 + def _write(root, rel_path, data):
  22 + full = os.path.join(root, *rel_path.split("/"))
  23 + os.makedirs(os.path.dirname(full), exist_ok=True)
  24 + with open(full, "wb") as handle:
  25 + handle.write(data)
  26 + return full
  27 +
  28 + def _build_scenario(self):
  29 + source_dir = tempfile.mkdtemp(prefix="sa-src-")
  30 + isolated_dir = tempfile.mkdtemp(prefix="sa-iso-")
  31 + self.addCleanup(shutil.rmtree, source_dir, ignore_errors=True)
  32 + self.addCleanup(shutil.rmtree, isolated_dir, ignore_errors=True)
  33 +
  34 + self._write(source_dir, "keep.txt", b"keep\n")
  35 + self._write(isolated_dir, "keep.txt", b"keep\n")
  36 +
  37 + self._write(source_dir, "mod.txt", b"original\n")
  38 + self._write(isolated_dir, "mod.txt", b"changed\n")
  39 +
  40 + self._write(source_dir, "old.txt", b"old content\n")
  41 +
  42 + self._write(isolated_dir, "new.txt", b"new content\n")
  43 +
  44 + source_snapshot = cm.snapshot_workspace(source_dir)
  45 + manifest = cm.build_change_manifest(source_snapshot, isolated_dir)
  46 + return source_dir, isolated_dir, manifest, source_snapshot
  47 +
  48 + def test_approved_paths_none_raises(self):
  49 + source_dir, isolated_dir, manifest, before = self._build_scenario()
  50 + with self.assertRaises(sa.SelectiveApplyError):
  51 + sa.apply_manifest(manifest, source_dir, isolated_dir, None)
  52 + after = cm.snapshot_workspace(source_dir)
  53 + self.assertEqual(before, after)
  54 +
  55 + def test_rejects_path_not_in_manifest_and_leaves_source_untouched(self):
  56 + source_dir, isolated_dir, manifest, before = self._build_scenario()
  57 + with self.assertRaises(sa.SelectiveApplyError):
  58 + sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt", "not_in_manifest.txt"])
  59 + after = cm.snapshot_workspace(source_dir)
  60 + self.assertEqual(before, after)
  61 +
  62 + def test_rejects_absolute_and_traversal_paths(self):
  63 + source_dir, isolated_dir, manifest, before = self._build_scenario()
  64 + for bad_path in ["/etc/passwd", "../escape.txt", "sub/../../escape.txt"]:
  65 + with self.assertRaises(sa.SelectiveApplyError):
  66 + sa.apply_manifest(manifest, source_dir, isolated_dir, [bad_path])
  67 + after = cm.snapshot_workspace(source_dir)
  68 + self.assertEqual(before, after)
  69 +
  70 + def test_applies_only_approved_subset_and_preserves_the_rest(self):
  71 + source_dir, isolated_dir, manifest, _ = self._build_scenario()
  72 + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"])
  73 +
  74 + self.assertEqual(result["applied"], ["mod.txt"])
  75 + self.assertEqual(sorted(result["skipped"]), ["new.txt", "old.txt"])
  76 + self.assertEqual(result["already_applied"], [])
  77 +
  78 + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle:
  79 + self.assertEqual(handle.read(), b"changed\n")
  80 + self.assertFalse(os.path.exists(os.path.join(source_dir, "new.txt")))
  81 + with open(os.path.join(source_dir, "old.txt"), "rb") as handle:
  82 + self.assertEqual(handle.read(), b"old content\n")
  83 + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle:
  84 + self.assertEqual(handle.read(), b"keep\n")
  85 +
  86 + def test_apply_add_modify_delete_full_manifest(self):
  87 + source_dir, isolated_dir, manifest, _ = self._build_scenario()
  88 + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt", "new.txt", "old.txt"])
  89 +
  90 + self.assertEqual(sorted(result["applied"]), ["mod.txt", "new.txt", "old.txt"])
  91 + self.assertEqual(result["skipped"], [])
  92 + self.assertEqual(result["already_applied"], [])
  93 +
  94 + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle:
  95 + self.assertEqual(handle.read(), b"changed\n")
  96 + with open(os.path.join(source_dir, "new.txt"), "rb") as handle:
  97 + self.assertEqual(handle.read(), b"new content\n")
  98 + self.assertFalse(os.path.exists(os.path.join(source_dir, "old.txt")))
  99 + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle:
  100 + self.assertEqual(handle.read(), b"keep\n")
  101 +
  102 + def test_source_drift_rejected_before_any_write(self):
  103 + source_dir, isolated_dir, manifest, expected_snapshot = self._build_scenario()
  104 +
  105 + self._write(source_dir, "keep.txt", b"drifted!\n")
  106 +
  107 + with self.assertRaises(sa.SelectiveApplyError):
  108 + sa.apply_manifest(
  109 + manifest, source_dir, isolated_dir, ["mod.txt", "new.txt", "old.txt"],
  110 + expected_source_snapshot=expected_snapshot,
  111 + )
  112 +
  113 + with open(os.path.join(source_dir, "mod.txt"), "rb") as handle:
  114 + self.assertEqual(handle.read(), b"original\n")
  115 + self.assertFalse(os.path.exists(os.path.join(source_dir, "new.txt")))
  116 + with open(os.path.join(source_dir, "old.txt"), "rb") as handle:
  117 + self.assertEqual(handle.read(), b"old content\n")
  118 + with open(os.path.join(source_dir, "keep.txt"), "rb") as handle:
  119 + self.assertEqual(handle.read(), b"drifted!\n")
  120 +
  121 + def test_idempotent_second_apply_reports_already_applied(self):
  122 + source_dir, isolated_dir, manifest, _ = self._build_scenario()
  123 + approved = ["mod.txt", "new.txt", "old.txt"]
  124 +
  125 + first = sa.apply_manifest(manifest, source_dir, isolated_dir, approved)
  126 + self.assertEqual(sorted(first["applied"]), sorted(approved))
  127 +
  128 + snapshot_after_first = cm.snapshot_workspace(source_dir)
  129 +
  130 + second = sa.apply_manifest(manifest, source_dir, isolated_dir, approved)
  131 + self.assertEqual(second["applied"], [])
  132 + self.assertEqual(sorted(second["already_applied"]), sorted(approved))
  133 +
  134 + snapshot_after_second = cm.snapshot_workspace(source_dir)
  135 + self.assertEqual(snapshot_after_first, snapshot_after_second)
  136 +
  137 + def test_symlink_destination_rejected(self):
  138 + source_dir = tempfile.mkdtemp(prefix="sa-src-")
  139 + isolated_dir = tempfile.mkdtemp(prefix="sa-iso-")
  140 + self.addCleanup(shutil.rmtree, source_dir, ignore_errors=True)
  141 + self.addCleanup(shutil.rmtree, isolated_dir, ignore_errors=True)
  142 +
  143 + real_target = self._write(source_dir, "real.txt", b"real\n")
  144 + symlink_path = os.path.join(source_dir, "linked.txt")
  145 + os.symlink(real_target, symlink_path)
  146 +
  147 + self._write(isolated_dir, "linked.txt", b"attacker controlled\n")
  148 +
  149 + source_snapshot = cm.snapshot_workspace(source_dir)
  150 + manifest = cm.build_change_manifest(source_snapshot, isolated_dir)
  151 + self.assertIn("linked.txt", [entry["path"] for entry in manifest["changed_files"]])
  152 +
  153 + with self.assertRaises(sa.SelectiveApplyError):
  154 + sa.apply_manifest(manifest, source_dir, isolated_dir, ["linked.txt"])
  155 +
  156 + self.assertTrue(os.path.islink(symlink_path))
  157 + self.assertEqual(os.readlink(symlink_path), real_target)
  158 + with open(real_target, "rb") as handle:
  159 + self.assertEqual(handle.read(), b"real\n")
  160 +
  161 + def test_result_is_json_serializable_and_carries_stable_identifier(self):
  162 + source_dir, isolated_dir, manifest, _ = self._build_scenario()
  163 + result = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"])
  164 +
  165 + json.dumps(result)
  166 +
  167 + self.assertIn("manifest_id", result)
  168 + self.assertIsInstance(result["manifest_id"], str)
  169 + self.assertTrue(result["manifest_id"])
  170 +
  171 + second = sa.apply_manifest(manifest, source_dir, isolated_dir, ["mod.txt"])
  172 + self.assertEqual(result["manifest_id"], second["manifest_id"])
  173 +
  174 +
  175 +if __name__ == "__main__":
  176 + unittest.main()
... ...
supervisor/isolation/test_workspace_security.py 0 → 100644
  1 +"""U-036: real unittest coverage for workspace_security.py -- adapted from
  2 +graphloop's test_workspace_security.py (2026-08-02). Module logic is
  3 +unchanged from graphloop ("adapted verbatim", see workspace_security.py's
  4 +own docstring); only the import path changed.
  5 +
  6 +Run: python3 -m unittest discover -s supervisor/isolation -p "test_*.py" -v
  7 +"""
  8 +
  9 +import os
  10 +import shutil
  11 +import sys
  12 +import tempfile
  13 +import unittest
  14 +
  15 +from supervisor.isolation import workspace_security as ws
  16 +
  17 +
  18 +class ValidateArtifactsTests(unittest.TestCase):
  19 + def setUp(self):
  20 + self.workspace = tempfile.mkdtemp(prefix="wssec-workspace-")
  21 + self.addCleanup(shutil.rmtree, self.workspace, ignore_errors=True)
  22 +
  23 + self.outside_dir = tempfile.mkdtemp(prefix="wssec-outside-")
  24 + self.addCleanup(shutil.rmtree, self.outside_dir, ignore_errors=True)
  25 +
  26 + def _write(self, relative_path, content="content"):
  27 + full_path = os.path.join(self.workspace, relative_path)
  28 + os.makedirs(os.path.dirname(full_path), exist_ok=True)
  29 + with open(full_path, "w", encoding="utf-8") as handle:
  30 + handle.write(content)
  31 + return full_path
  32 +
  33 + def test_accepts_and_normalizes_plain_relative_artifacts(self):
  34 + self._write("output.txt")
  35 + self._write(os.path.join("nested", "dir", "file.txt"))
  36 +
  37 + result = ws.validate_artifacts(["output.txt", "nested/dir/file.txt"], self.workspace)
  38 +
  39 + self.assertEqual(result, ["output.txt", "nested/dir/file.txt"])
  40 +
  41 + def test_normalizes_redundant_leading_dot_slash(self):
  42 + self._write("output.txt")
  43 +
  44 + result = ws.validate_artifacts(["./output.txt"], self.workspace)
  45 +
  46 + self.assertEqual(result, ["output.txt"])
  47 +
  48 + def test_rejects_non_string_item(self):
  49 + self._write("output.txt")
  50 +
  51 + with self.assertRaises(ws.WorkspaceSecurityError):
  52 + ws.validate_artifacts(["output.txt", 42], self.workspace)
  53 +
  54 + def test_rejects_non_list_artifacts_argument(self):
  55 + with self.assertRaises(ws.WorkspaceSecurityError):
  56 + ws.validate_artifacts("output.txt", self.workspace)
  57 +
  58 + def test_rejects_absolute_path_artifact(self):
  59 + absolute = self._write("output.txt")
  60 +
  61 + with self.assertRaises(ws.WorkspaceSecurityError):
  62 + ws.validate_artifacts([absolute], self.workspace)
  63 +
  64 + def test_rejects_dot_dot_traversal_even_when_target_exists(self):
  65 + self._write("output.txt")
  66 + os.makedirs(os.path.join(self.workspace, "sibling"), exist_ok=True)
  67 +
  68 + with self.assertRaises(ws.WorkspaceSecurityError):
  69 + ws.validate_artifacts(["sibling/../output.txt"], self.workspace)
  70 +
  71 + def test_rejects_dot_dot_traversal_escaping_workspace(self):
  72 + secret = os.path.join(self.outside_dir, "secret.txt")
  73 + with open(secret, "w", encoding="utf-8") as handle:
  74 + handle.write("secret")
  75 +
  76 + with self.assertRaises(ws.WorkspaceSecurityError):
  77 + ws.validate_artifacts(
  78 + [os.path.join("..", os.path.basename(self.outside_dir), "secret.txt")],
  79 + self.workspace,
  80 + )
  81 +
  82 + def test_rejects_missing_artifact(self):
  83 + with self.assertRaises(ws.WorkspaceSecurityError):
  84 + ws.validate_artifacts(["does_not_exist.txt"], self.workspace)
  85 +
  86 + def test_rejects_symlink_artifact(self):
  87 + real_file = self._write("real.txt")
  88 + symlink_path = os.path.join(self.workspace, "link.txt")
  89 + os.symlink(real_file, symlink_path)
  90 + self.addCleanup(lambda: os.path.exists(symlink_path) and os.remove(symlink_path))
  91 +
  92 + with self.assertRaises(ws.WorkspaceSecurityError):
  93 + ws.validate_artifacts(["link.txt"], self.workspace)
  94 +
  95 + def test_rejects_artifact_resolving_outside_workspace_via_symlinked_directory(self):
  96 + secret = os.path.join(self.outside_dir, "secret.txt")
  97 + with open(secret, "w", encoding="utf-8") as handle:
  98 + handle.write("secret")
  99 +
  100 + symlinked_dir = os.path.join(self.workspace, "escape")
  101 + os.symlink(self.outside_dir, symlinked_dir, target_is_directory=True)
  102 + self.addCleanup(lambda: os.path.exists(symlinked_dir) and os.remove(symlinked_dir))
  103 +
  104 + with self.assertRaises(ws.WorkspaceSecurityError):
  105 + ws.validate_artifacts(["escape/secret.txt"], self.workspace)
  106 +
  107 + def test_rejects_empty_string_artifact(self):
  108 + with self.assertRaises(ws.WorkspaceSecurityError):
  109 + ws.validate_artifacts([""], self.workspace)
  110 +
  111 +
  112 +class ValidateSubprocessCommandTests(unittest.TestCase):
  113 + def setUp(self):
  114 + self.workspace = tempfile.mkdtemp(prefix="wssec-cmd-workspace-")
  115 + self.addCleanup(shutil.rmtree, self.workspace, ignore_errors=True)
  116 +
  117 + self.adapter_script = os.path.join(self.workspace, "existing_adapter.py")
  118 + with open(self.adapter_script, "w", encoding="utf-8") as handle:
  119 + handle.write("print('ok')\n")
  120 +
  121 + def test_accepts_python_executable_with_existing_script(self):
  122 + result = ws.validate_subprocess_command([sys.executable, self.adapter_script])
  123 +
  124 + self.assertEqual(result, [sys.executable, self.adapter_script])
  125 +
  126 + def test_rejects_empty_command(self):
  127 + with self.assertRaises(ws.WorkspaceSecurityError):
  128 + ws.validate_subprocess_command([])
  129 +
  130 + def test_rejects_non_list_command(self):
  131 + with self.assertRaises(ws.WorkspaceSecurityError):
  132 + ws.validate_subprocess_command("{} {}".format(sys.executable, self.adapter_script))
  133 +
  134 + def test_rejects_non_string_argument(self):
  135 + with self.assertRaises(ws.WorkspaceSecurityError):
  136 + ws.validate_subprocess_command([sys.executable, self.adapter_script, 7])
  137 +
  138 + def test_rejects_shell_metacharacters_in_arguments(self):
  139 + dangerous_args = [
  140 + "foo; rm -rf /", "foo && rm -rf /", "foo | rm -rf /",
  141 + "foo > /etc/passwd", "foo `rm -rf /`", "foo $(rm -rf /)",
  142 + ]
  143 + for dangerous_arg in dangerous_args:
  144 + with self.subTest(dangerous_arg=dangerous_arg):
  145 + with self.assertRaises(ws.WorkspaceSecurityError):
  146 + ws.validate_subprocess_command([sys.executable, self.adapter_script, dangerous_arg])
  147 +
  148 + def test_rejects_bare_shell_invocation(self):
  149 + with self.assertRaises(ws.WorkspaceSecurityError):
  150 + ws.validate_subprocess_command(["/bin/sh", "-c", "echo hi"])
  151 +
  152 + def test_rejects_script_argument_with_disallowed_extension(self):
  153 + non_script = os.path.join(self.workspace, "not_a_script.txt")
  154 + with open(non_script, "w", encoding="utf-8") as handle:
  155 + handle.write("not python")
  156 +
  157 + with self.assertRaises(ws.WorkspaceSecurityError):
  158 + ws.validate_subprocess_command([sys.executable, non_script])
  159 +
  160 + def test_rejects_script_argument_that_does_not_exist(self):
  161 + missing_script = os.path.join(self.workspace, "missing_adapter.py")
  162 +
  163 + with self.assertRaises(ws.WorkspaceSecurityError):
  164 + ws.validate_subprocess_command([sys.executable, missing_script])
  165 +
  166 + def test_rejects_newline_in_argument(self):
  167 + with self.assertRaises(ws.WorkspaceSecurityError):
  168 + ws.validate_subprocess_command([sys.executable, self.adapter_script, "foo\nbar"])
  169 +
  170 +
  171 +if __name__ == "__main__":
  172 + unittest.main()
... ...
supervisor/test_deploy_supervisor.py 0 → 100644
  1 +"""U-036 tests: Deploy Supervisor process skeleton -- lifecycle only (PID
  2 +file, status file, start/stop), no actual deploy logic (that's U-037+).
  3 +GRAPHLEMON_HOME is always overridden to a throwaway tempdir so these tests
  4 +never touch the real user's ~/.graphlemon.
  5 +
  6 +Run: python3 -m unittest discover -s supervisor -p "test_*.py" -v
  7 +"""
  8 +
  9 +import os
  10 +import shutil
  11 +import signal
  12 +import subprocess
  13 +import sys
  14 +import tempfile
  15 +import time
  16 +import unittest
  17 +
  18 +from supervisor import deploy_supervisor as ds
  19 +from src import paths
  20 +
  21 +
  22 +class DeploySupervisorTestBase(unittest.TestCase):
  23 + def setUp(self):
  24 + self.tmp = tempfile.mkdtemp(prefix="deploy-supervisor-test-")
  25 + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
  26 + self.old_home = os.environ.get("GRAPHLEMON_HOME")
  27 + os.environ["GRAPHLEMON_HOME"] = self.tmp
  28 +
  29 + def tearDown(self):
  30 + if self.old_home is None:
  31 + os.environ.pop("GRAPHLEMON_HOME", None)
  32 + else:
  33 + os.environ["GRAPHLEMON_HOME"] = self.old_home
  34 +
  35 +
  36 +class RuntimePathsTest(DeploySupervisorTestBase):
  37 + def test_runtime_dir_created_under_platform_home(self):
  38 + d = ds.runtime_dir()
  39 + self.assertTrue(os.path.isdir(d))
  40 + self.assertTrue(d.startswith(paths.platform_home()))
  41 +
  42 + def test_pid_and_status_file_paths_live_under_runtime_dir(self):
  43 + self.assertEqual(os.path.dirname(ds.pid_file_path()), ds.runtime_dir())
  44 + self.assertEqual(os.path.dirname(ds.status_file_path()), ds.runtime_dir())
  45 +
  46 +
  47 +class IsAliveTest(unittest.TestCase):
  48 + def test_current_process_is_alive(self):
  49 + self.assertTrue(ds.is_alive(os.getpid()))
  50 +
  51 + def test_zero_or_negative_pid_is_not_alive(self):
  52 + self.assertFalse(ds.is_alive(0))
  53 + self.assertFalse(ds.is_alive(-1))
  54 +
  55 + def test_non_int_is_not_alive(self):
  56 + self.assertFalse(ds.is_alive("not-a-pid"))
  57 +
  58 + def test_a_pid_that_does_not_exist_is_not_alive(self):
  59 + # PID_MAX on Linux/macOS is well below this; astronomically unlikely
  60 + # to collide with a real running process.
  61 + self.assertFalse(ds.is_alive(2**30))
  62 +
  63 +
  64 +class StatusFileTest(DeploySupervisorTestBase):
  65 + def test_write_then_read_status_round_trips(self):
  66 + ds.write_status("running", detail="test detail")
  67 + status = ds.read_status()
  68 + self.assertEqual(status["status"], "running")
  69 + self.assertEqual(status["detail"], "test detail")
  70 + self.assertEqual(status["pid"], os.getpid())
  71 +
  72 + def test_read_status_returns_none_when_no_file_yet(self):
  73 + self.assertIsNone(ds.read_status())
  74 +
  75 + def test_write_status_is_atomic_no_tmp_file_left_behind(self):
  76 + ds.write_status("running")
  77 + entries = os.listdir(ds.runtime_dir())
  78 + self.assertEqual(entries, ["supervisor.status.json"])
  79 +
  80 +
  81 +class IsRunningTest(DeploySupervisorTestBase):
  82 + def test_false_when_no_pid_file(self):
  83 + self.assertFalse(ds.is_running())
  84 +
  85 + def test_true_when_pid_file_points_to_a_live_process(self):
  86 + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle:
  87 + handle.write(str(os.getpid()))
  88 + self.assertTrue(ds.is_running())
  89 +
  90 + def test_false_when_pid_file_points_to_a_dead_process(self):
  91 + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle:
  92 + handle.write(str(2**30))
  93 + self.assertFalse(ds.is_running())
  94 +
  95 + def test_false_when_pid_file_is_corrupt(self):
  96 + with open(ds.pid_file_path(), "w", encoding="utf-8") as handle:
  97 + handle.write("not-a-number")
  98 + self.assertFalse(ds.is_running())
  99 +
  100 +
  101 +class RunForegroundTest(DeploySupervisorTestBase):
  102 + def test_writes_pid_file_and_running_status_during_the_loop(self):
  103 + seen_running = []
  104 +
  105 + def poll_fn():
  106 + seen_running.append(ds.is_running())
  107 + seen_running.append(ds.read_status()["status"])
  108 +
  109 + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=1)
  110 +
  111 + self.assertEqual(seen_running, [True, "running"])
  112 +
  113 + def test_calls_poll_fn_exactly_iterations_times(self):
  114 + calls = []
  115 + ds.run_foreground(poll_fn=lambda: calls.append(1), poll_interval=0, iterations=3)
  116 + self.assertEqual(len(calls), 3)
  117 +
  118 + def test_writes_stopped_status_and_removes_pid_file_on_normal_completion(self):
  119 + ds.run_foreground(poll_fn=None, poll_interval=0, iterations=1)
  120 + self.assertEqual(ds.read_status()["status"], "stopped")
  121 + self.assertFalse(os.path.exists(ds.pid_file_path()))
  122 +
  123 + def test_sigterm_breaks_the_loop_and_still_writes_stopped_status(self):
  124 + calls = []
  125 +
  126 + def poll_fn():
  127 + calls.append(1)
  128 + if len(calls) == 2:
  129 + os.kill(os.getpid(), signal.SIGTERM)
  130 +
  131 + # iterations=None (would run forever) -- SIGTERM is the only exit.
  132 + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=None)
  133 +
  134 + self.assertEqual(len(calls), 2)
  135 + self.assertEqual(ds.read_status()["status"], "stopped")
  136 + self.assertFalse(os.path.exists(ds.pid_file_path()))
  137 +
  138 + def test_poll_fn_exception_propagates_but_cleanup_still_runs(self):
  139 + def poll_fn():
  140 + raise RuntimeError("boom")
  141 +
  142 + with self.assertRaises(RuntimeError):
  143 + ds.run_foreground(poll_fn=poll_fn, poll_interval=0, iterations=1)
  144 +
  145 + self.assertEqual(ds.read_status()["status"], "stopped")
  146 + self.assertFalse(os.path.exists(ds.pid_file_path()))
  147 +
  148 +
  149 +class RealSubprocessLifecycleTest(DeploySupervisorTestBase):
  150 + """Not mocked -- actually spawns `python3 -m supervisor.deploy_supervisor`
  151 + as a real independent OS process and stops it for real, per this Unit's
  152 + Done criterion ("骨架能独立启停")."""
  153 +
  154 + def test_start_detached_then_stop(self):
  155 + env = dict(os.environ)
  156 + # start_detached itself calls is_running()/Popen using the current
  157 + # process's env var (already patched by setUp); the child process
  158 + # inherits it via Popen's default env=None -> os.environ passthrough.
  159 + pid = ds.start_detached(poll_interval=0.1)
  160 + try:
  161 + deadline = time.time() + 5
  162 + while time.time() < deadline and not ds.is_running():
  163 + time.sleep(0.05)
  164 + self.assertTrue(ds.is_running())
  165 + self.assertTrue(ds.is_alive(pid))
  166 +
  167 + stopped = ds.stop(timeout=5)
  168 + self.assertTrue(stopped)
  169 + self.assertFalse(ds.is_running())
  170 + finally:
  171 + if ds.is_alive(pid):
  172 + os.kill(pid, signal.SIGKILL)
  173 +
  174 + def test_start_detached_twice_raises_while_already_running(self):
  175 + pid = ds.start_detached(poll_interval=0.1)
  176 + try:
  177 + deadline = time.time() + 5
  178 + while time.time() < deadline and not ds.is_running():
  179 + time.sleep(0.05)
  180 + self.assertTrue(ds.is_running())
  181 + with self.assertRaises(RuntimeError):
  182 + ds.start_detached(poll_interval=0.1)
  183 + finally:
  184 + ds.stop(timeout=5)
  185 + if ds.is_alive(pid):
  186 + os.kill(pid, signal.SIGKILL)
  187 +
  188 + def test_stop_returns_false_when_nothing_is_running(self):
  189 + self.assertFalse(ds.stop(timeout=1))
  190 +
  191 +
  192 +if __name__ == "__main__":
  193 + unittest.main()
... ...