merge(chv-muse-round12): 合入 P7 状态与记忆分离 (29fa0c8)

冲突按并集解:build.sh 保留 P2+P3+P4 检查/拷入 + P7 的
chv-memory 检查/拷入(P7 的重复 runcmd 行删掉——同一文件
写两次会盖掉 authd 启动行);buildinfo.py 加 chv-memory 一项;
production-readiness.md P7 一节顺延为 §18。P7 测试 46 项全过。
This commit is contained in:
host committed 2026-11-07 00:02:54 +00:00
commit 320cd36921
8 files changed
+1667 -1

No files matched your search

+502
View File
@@ -0,0 +1,502 @@
"""对齐 Muse P7 测试:状态与记忆分离。
只测本轨交付物(cloud/chv/app_state.py、memory_files.py、
chv-guest/chv-memory.py、build.sh 干跑、buildinfo.py 内容项)。
真实记忆系统 CLI 只在本地临时 home 上跑(init/write/search/
forget/backup/restore 全是真调用);其余用假 ssh/假 CLI。
绝不连真实网络、不起真实 VM、不改生产行为。
"""
import importlib.util
import io
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WORKSPACE = Path(__file__).resolve().parents[3]
MEMSYS_CLI = WORKSPACE / "memory-system" / "dist" / "cli.js"
sys.path.insert(0, str(ROOT))
from cloud.chv import app_state
from cloud.chv import memory_files
from cloud.chv.app_state import (AppStateStore, SecretRejectedError,
restore_app_state, state_db_path,
verify_backup)
from cloud.chv.memory_files import (GUEST_MEMORY_HOME, MemoryFiles,
MemorySystemClient,
build_pull_command, forget,
memory_home_path, pull_memory_home)
def _load_guest_script(name):
"""把 chv-guest 下的独立脚本当模块加载(无包结构)。"""
path = ROOT / "chv-guest" / name
spec = importlib.util.spec_from_file_location(
"chv_guest_p7_" + name[:-3], str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CHV_MEMORY = _load_guest_script("chv-memory.py")
BUILDINFO = _load_guest_script("buildinfo.py")
def _has_cli():
"""真实记忆系统 CLI 是否在位。"""
return os.path.isfile(str(MEMSYS_CLI)) and shutil.which("node")
class SecretGuardTest(unittest.TestCase):
def test_key_name_token_rejected(self):
with self.assertRaises(SecretRejectedError) as ctx:
app_state.assert_no_secrets({"GITEA_TOKEN": "abc"})
self.assertNotIn("abc", str(ctx.exception))
def test_key_name_password_rejected(self):
with self.assertRaises(SecretRejectedError):
app_state.assert_no_secrets({"nested": {"db_password": "x"}})
def test_value_pattern_ghp_rejected(self):
with self.assertRaises(SecretRejectedError) as ctx:
app_state.assert_no_secrets(
{"note": "see ghp_" + "a" * 24 + " here"})
self.assertNotIn("ghp_", str(ctx.exception))
def test_plain_text_passes(self):
app_state.assert_no_secrets(
{"title": "买菜", "note": "记得买冰美式豆子"})
class AppStateStoreTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "vm1", "app-state.sqlite")
self.store = AppStateStore(self.path)
self.store.open()
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def test_open_sets_schema_and_modes(self):
self.assertEqual(self.store.schema_version(), "1")
self.assertEqual(oct(os.stat(self.path).st_mode & 0o777), "0o600")
parent = os.path.dirname(self.path)
self.assertEqual(oct(os.stat(parent).st_mode & 0o777), "0o700")
def test_state_db_path_helper(self):
self.assertEqual(state_db_path("/s", "vm1"),
"/s/vm1/app-state.sqlite")
def test_task_roundtrip(self):
self.store.put_task("u1", "t1", "code", "running",
{"repo": "admin/x", "step": "build"})
got = self.store.get_task("t1")
self.assertEqual(got["status"], "running")
self.assertEqual(got["payload"]["repo"], "admin/x")
self.assertIsNone(self.store.get_task("nope"))
listed = self.store.list_tasks("u1")
self.assertEqual([t["id"] for t in listed], ["t1"])
self.assertEqual(self.store.list_tasks("u1", "done"), [])
def test_update_task_status(self):
self.store.put_task("u1", "t1", "code", "running", {})
self.assertTrue(self.store.update_task_status("t1", "done"))
self.assertEqual(self.store.get_task("t1")["status"], "done")
self.assertFalse(self.store.update_task_status("nope", "done"))
def test_goal_and_session_roundtrip(self):
self.store.put_goal("u1", "g1", "学会做饭",
{"steps": ["买菜", "开火"]}, "active")
goal = self.store.get_goal("g1")
self.assertEqual(goal["title"], "学会做饭")
self.assertEqual(goal["plan"]["steps"][0], "买菜")
self.assertEqual(len(self.store.list_goals("u1")), 1)
self.store.put_session("u1", "s1", "chat-9", "聊了晚饭")
session = self.store.get_session("s1")
self.assertEqual(session["chat_id"], "chat-9")
self.assertIsNone(self.store.get_session("nope"))
def test_task_with_secret_key_refused_and_not_stored(self):
with self.assertRaises(SecretRejectedError):
self.store.put_task("u1", "t2", "code", "running",
{"GITEA_TOKEN": "abc"})
self.assertIsNone(self.store.get_task("t2"))
def test_task_with_secret_value_refused(self):
with self.assertRaises(SecretRejectedError):
self.store.put_task("u1", "t2", "code", "running",
{"note": "key sk-" + "b" * 24})
def test_audit_append_only(self):
rid = self.store.append_audit("host", "task-start", "t1",
{"kind": "code"})
self.assertTrue(rid)
rows = self.store.list_audit(target="t1")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["action"], "task-start")
source = Path(app_state.__file__).read_text()
self.assertNotIn("DELETE FROM audit", source)
self.assertNotIn("UPDATE audit", source)
def test_audit_detail_secret_refused(self):
with self.assertRaises(SecretRejectedError):
self.store.append_audit("host", "x", "t1",
{"token": "ghp_" + "c" * 24})
def test_counts(self):
self.store.put_task("u1", "t1", "code", "running", {})
self.store.put_goal("u1", "g1", "标题", {}, "active")
self.assertEqual(self.store.counts()["tasks"], 1)
self.assertEqual(self.store.counts()["goals"], 1)
self.assertEqual(self.store.counts()["audit"], 0)
def test_ddl_has_no_sqlite_only_syntax(self):
self.assertNotIn("AUTOINCREMENT", app_state.DDL)
self.assertNotIn("PRAGMA", app_state.DDL)
for keyword in ("CREATE TABLE IF NOT EXISTS meta",
"CREATE TABLE IF NOT EXISTS tasks",
"CREATE TABLE IF NOT EXISTS goals",
"CREATE TABLE IF NOT EXISTS sessions",
"CREATE TABLE IF NOT EXISTS audit"):
self.assertIn(keyword, app_state.DDL)
def test_backup_restore_roundtrip(self):
self.store.put_task("u1", "t1", "code", "done", {"n": "1"})
self.store.append_audit("host", "done", "t1", {})
snap = os.path.join(self.tmp.name, "snap")
manifest = self.store.backup(snap)
self.assertEqual(manifest["tasks"], "1")
self.assertEqual(verify_backup(snap)["sha256"],
manifest["sha256"])
self.store.close()
os.remove(self.path)
restore_app_state(snap, self.path)
self.store.open()
self.assertEqual(self.store.get_task("t1")["status"], "done")
self.assertEqual(self.store.counts()["audit"], 1)
def test_restore_keeps_pre_restore_copy(self):
self.store.put_task("u1", "t1", "code", "running", {})
snap = os.path.join(self.tmp.name, "snap")
self.store.backup(snap)
self.store.close()
restore_app_state(snap, self.path)
preserved = [p for p in os.listdir(os.path.dirname(self.path))
if p.startswith("app-state.sqlite.pre-restore-")]
self.assertEqual(len(preserved), 1)
def test_restore_corrupt_backup_refused(self):
self.store.put_task("u1", "t1", "code", "running", {})
snap = os.path.join(self.tmp.name, "snap")
self.store.backup(snap)
self.store.close()
with open(os.path.join(snap, "app-state.sqlite"),
"ab") as handle:
handle.write(b"corrupt")
with self.assertRaises(app_state.AppStateError):
restore_app_state(snap, self.path)
self.store.open()
self.assertEqual(self.store.get_task("t1")["status"], "running")
def test_backup_has_no_token_bytes(self):
sentinel = "ghp_" + "z" * 24
with self.assertRaises(SecretRejectedError):
self.store.put_task("u1", "t9", "code", "running",
{"note": sentinel})
snap = os.path.join(self.tmp.name, "snap")
self.store.backup(snap)
blob = Path(os.path.join(snap, "app-state.sqlite")).read_bytes()
self.assertNotIn(b"ghp_", blob)
manifest = Path(os.path.join(snap, "manifest.json")).read_bytes()
self.assertNotIn(b"ghp_", manifest)
def test_store_not_open_raises(self):
closed = AppStateStore(os.path.join(self.tmp.name, "x.db"))
with self.assertRaises(app_state.AppStateError):
closed.get_task("t1")
class SeparationTest(unittest.TestCase):
def test_store_path_outside_guest_disk(self):
db = state_db_path("/state", "vm1")
disk = "/state/vm1/disk.raw"
self.assertNotEqual(db, disk)
self.assertFalse(db.startswith(disk + "/"))
def test_memory_home_path_outside_guest_disk(self):
home = memory_home_path("/state", "vm1")
self.assertEqual(home, "/state/vm1/memory-home")
self.assertNotEqual(home, "/state/vm1/disk.raw")
def test_guest_script_has_no_state_db_reference(self):
source = (ROOT / "chv-guest" / "chv-memory.py").read_text()
self.assertNotIn("app-state", source)
self.assertNotIn("app_state", source)
def test_app_state_has_no_guest_paths(self):
source = Path(app_state.__file__).read_text()
self.assertNotIn("/home/chv", source)
self.assertNotIn("GUEST_MEMORY_HOME", source)
def test_guest_list_cannot_see_store_file(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
store = os.path.join(tmp, "app-state.sqlite")
Path(store).write_text("fake-db")
CHV_MEMORY._ensure(home)
listed = CHV_MEMORY.cmd_list(home)["files"]
self.assertNotIn("app-state.sqlite", listed)
self.assertNotIn("../app-state.sqlite", listed)
class MemoryFilesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.home = os.path.join(self.tmp.name, "memory-home")
self.files = MemoryFiles(self.home)
self.files.ensure()
def tearDown(self):
self.tmp.cleanup()
def test_ensure_layout(self):
self.assertTrue(os.path.isfile(
os.path.join(self.home, "MEMORY.md")))
self.assertTrue(os.path.isdir(
os.path.join(self.home, "memory")))
self.assertEqual(oct(os.stat(self.home).st_mode & 0o777), "0o700")
def test_memory_home_path_helper(self):
self.assertEqual(memory_home_path("/s", "vm1"),
"/s/vm1/memory-home")
def test_write_read_list_append(self):
self.files.write_file("MEMORY.md", "# 记忆\n", actor="host")
self.files.append_lines("memory/2026-11-07.md",
["- 喜欢冰美式", "- 怕辣"], actor="host")
self.assertIn("MEMORY.md", self.files.list_files())
self.assertIn("memory/2026-11-07.md", self.files.list_files())
text = self.files.read_file("memory/2026-11-07.md")
self.assertIn("冰美式", text)
audit = Path(os.path.join(
self.home, "memory", "audit.jsonl")).read_text()
self.assertIn('"action": "write"', audit)
self.assertIn('"action": "append"', audit)
def test_read_missing_raises(self):
with self.assertRaises(memory_files.MemoryError):
self.files.read_file("memory/nope.md")
def test_path_traversal_rejected(self):
for bad in ("../escape.md", "../../etc/passwd",
"/etc/passwd", "memory/../../escape.md"):
with self.assertRaises(memory_files.MemoryError, msg=bad):
self.files.read_file(bad)
with self.assertRaises(memory_files.MemoryError, msg=bad):
self.files.write_file(bad, "x")
def test_write_secret_refused(self):
with self.assertRaises(SecretRejectedError):
self.files.write_file("MEMORY.md", "token ghp_" + "d" * 24)
def test_remove_lines(self):
self.files.write_file("MEMORY.md",
"# 记忆\n- 喜欢冰美式\n- 怕辣\n")
removed = self.files.remove_lines("MEMORY.md", "冰美式")
self.assertEqual(removed, 1)
self.assertNotIn("冰美式", self.files.read_file("MEMORY.md"))
self.assertIn("怕辣", self.files.read_file("MEMORY.md"))
class ForgetLocalOnlyTest(unittest.TestCase):
def test_forget_local_only_marks_pending(self):
with tempfile.TemporaryDirectory() as tmp:
files = MemoryFiles(os.path.join(tmp, "home"))
files.ensure()
files.write_file("MEMORY.md", "# 记忆\n- 喜欢冰美式\n")
result = forget(files, None, "冰美式", actor="host")
self.assertEqual(result["mode"], "local-only")
self.assertEqual(result["lines_removed"], 1)
self.assertFalse(result["verified"])
self.assertNotIn("冰美式", files.read_file("MEMORY.md"))
audit = Path(os.path.join(
tmp, "home", "memory", "audit.jsonl")).read_text()
self.assertIn("forget-pending", audit)
@unittest.skipUnless(_has_cli(), "memory-system CLI not available")
class RealCliForgetTest(unittest.TestCase):
def test_real_cli_write_search_forget_verify(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "home")
client = MemorySystemClient(str(MEMSYS_CLI))
self.assertTrue(client.available())
client._run(["init", home])
written = client._run([
"write", home, "--kind", "fact",
"--text", "用户喜欢喝冰美式",
"--chat", "c1", "--message", "m1",
"--speaker", "user", "--quote", "我喜欢喝冰美式"])
claim_id = written.get("id")
self.assertTrue(str(claim_id).startswith("claim:"))
found = client.search(home, "冰美式")
hits = found.get("hits")
self.assertTrue(isinstance(hits, list) and len(hits) == 1)
files = MemoryFiles(home)
files.write_file("MEMORY.md",
"# 记忆\n- 用户喜欢喝冰美式\n")
result = forget(files, client, "冰美式", actor="host")
self.assertEqual(result["mode"], "cli")
self.assertEqual(result["claims"], 1)
self.assertTrue(result["verified"])
after = client.search(home, "冰美式")
self.assertEqual(after.get("hits"), [])
self.assertNotIn("冰美式", files.read_file("MEMORY.md"))
def test_real_cli_backup_restore(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "home")
client = MemorySystemClient(str(MEMSYS_CLI))
client._run(["init", home])
client._run(["write", home, "--kind", "fact",
"--text", "用户住在上海", "--chat", "c1",
"--message", "m1", "--speaker", "user",
"--quote", "我住在上海"])
snap = client.backup(home)
snapshot = snap.get("snapshot") or snap.get("path") or ""
self.assertTrue(snapshot)
victim = os.path.join(home, "MEMORY.md")
with open(victim, "w", encoding="utf-8") as handle:
handle.write("# 已破坏\n")
client.restore(home, str(snapshot))
# 恢复后 MEMORY.md 回到快照内容(不再是破坏后的文本);
# claim 本身活在 bank + 每日笔记里,search 能查到才是
# 恢复成功的证据。
self.assertNotIn("已破坏", Path(victim).read_text())
found = client.search(home, "上海")
self.assertTrue(len(found.get("hits", [])) >= 1)
def test_client_failure_raises(self):
with tempfile.TemporaryDirectory() as tmp:
fake = os.path.join(tmp, "fake-cli.js")
Path(fake).write_text("#!/usr/bin/env node\n"
"process.exit(1);\n")
client = MemorySystemClient(fake)
with self.assertRaises(memory_files.MemoryError):
client.search(os.path.join(tmp, "home"), "x")
class PullMemoryHomeTest(unittest.TestCase):
def test_build_pull_command(self):
cmd = build_pull_command(
["ssh", "-p", "2222", "chv@127.0.0.1"], "/home/chv/memory")
self.assertEqual(cmd, ["ssh", "-p", "2222", "chv@127.0.0.1",
"tar -czf - -C /home/chv memory"])
def test_pull_with_fake_ssh(self):
with tempfile.TemporaryDirectory() as tmp:
remote_parent = os.path.join(tmp, "remote")
remote_home = os.path.join(remote_parent, "memory")
os.makedirs(os.path.join(remote_home, "memory"))
Path(os.path.join(remote_home, "MEMORY.md")).write_text(
"# 记忆\n")
Path(os.path.join(remote_home, "memory", "2026-11-07.md")
).write_text("- 喜欢冰美式\n")
fake_ssh = os.path.join(tmp, "fake-ssh")
Path(fake_ssh).write_text(
"#!/bin/sh\n"
"tar -czf - -C %s memory\n" % remote_parent)
os.chmod(fake_ssh, 0o755)
local = os.path.join(tmp, "local")
result = pull_memory_home([fake_ssh], GUEST_MEMORY_HOME,
local)
self.assertEqual(result["files"], 2)
self.assertIn("冰美式", Path(os.path.join(
local, "memory", "memory", "2026-11-07.md")).read_text())
def test_pull_ssh_failure_raises(self):
with tempfile.TemporaryDirectory() as tmp:
fake_ssh = os.path.join(tmp, "fake-ssh")
Path(fake_ssh).write_text("#!/bin/sh\nexit 3\n")
os.chmod(fake_ssh, 0o755)
with self.assertRaises(memory_files.MemoryError):
pull_memory_home([fake_ssh], GUEST_MEMORY_HOME,
os.path.join(tmp, "local"))
class GuestScriptTest(unittest.TestCase):
def test_list_read_append_forget_export(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
self.assertEqual(
CHV_MEMORY.cmd_list(home)["files"], ["MEMORY.md"])
CHV_MEMORY.cmd_append(home, "MEMORY.md",
["- 喜欢冰美式", "- 怕辣"])
read = CHV_MEMORY.cmd_read(home, "MEMORY.md")
self.assertIn("冰美式", read["text"])
forgot = CHV_MEMORY.cmd_forget(home, "冰美式")
self.assertEqual(forgot["removed"], 1)
self.assertNotIn("冰美式",
CHV_MEMORY.cmd_read(home, "MEMORY.md")["text"])
dest = os.path.join(tmp, "export")
exported = CHV_MEMORY.cmd_export(home, dest)
self.assertEqual(exported["files"], 1)
self.assertTrue(os.path.isfile(
os.path.join(dest, "MEMORY.md")))
def test_main_list_prints_json(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
buf = io.StringIO()
with redirect_stdout(buf):
rc = CHV_MEMORY.main(["--home", home, "list"])
self.assertEqual(rc, 0)
self.assertEqual(json.loads(buf.getvalue())["files"],
["MEMORY.md"])
def test_read_traversal_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
with self.assertRaises(SystemExit):
CHV_MEMORY.cmd_read(home, "../escape.md")
def test_append_secret_refused(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
with self.assertRaises(SystemExit):
CHV_MEMORY.cmd_append(home, "MEMORY.md",
["token ghp_" + "e" * 24])
def test_export_excludes_audit_log(self):
with tempfile.TemporaryDirectory() as tmp:
home = os.path.join(tmp, "memory")
CHV_MEMORY._ensure(home)
Path(os.path.join(home, "audit.jsonl")).write_text("{}\n")
dest = os.path.join(tmp, "export")
CHV_MEMORY.cmd_export(home, dest)
self.assertFalse(os.path.exists(
os.path.join(dest, "audit.jsonl")))
class BuildIntegrationTest(unittest.TestCase):
def test_build_sh_copies_chv_memory(self):
text = (ROOT / "chv-guest" / "build.sh").read_text()
self.assertIn("chv-memory.py", text)
self.assertIn("/usr/local/bin/chv-memory", text)
def test_buildinfo_lists_chv_memory(self):
self.assertIn("chv-memory", BUILDINFO.BASE_CONTENTS)
if __name__ == "__main__":
unittest.main()