round-12: migrate P8 registries + P12 audit to AppStateStore

- app_state.py: host_state_db_path() (host-level DB, distinct from
  per-VM state_db_path); kv_get/kv_put/kv_delete on meta (namespaced
  keys; kv_put skips the no-secrets guard by design — registry docs
  hold user prompts, documented); audit_trail table (P12 record
  shape + hash chain, seq explicit); counts()/backup() gain
  audit_trail.
- swarm.py / presentation.py / browser_tasks.py: JSON-file backends
  replaced by KV rows (p8-swarm / p8-ledger / p8-browser-tasks);
  legacy JSON imported once on first load (renamed .migrated);
  corrupt row / unreadable store returns empty (fail-safe, same
  semantics as before; next successful save self-heals).
- audit_trail.py: StoreAuditTrail (same record shape, hash chain,
  filters, export as JSONL version, backed by audit_trail table);
  migrate_jsonl_to_store() moves a chain verbatim (broken chain
  refused, divergent seq refused, re-run idempotent). JSONL class
  kept for reading old files.
- P12 dict contracts now run against real P3/P5 code
  (ApprovalQueue.record -> build_approval_card ->
  card_matches_channel; CapabilityIssuer.issue -> describe ->
  PermissionRegistry.record_grant).
- P6->P3 local protocol test: real PrivsepClient + real worker
  server + real git status; worker git argv carries the real P3
  helper + useHttpPath; no token in argv/env/audit. runuser + HTTP
  pull stay real-machine-only (see gap docs).
- Gap docs updated: P8 migration done, guest-side browser follow-up
  explicitly left for the real-machine track (no task_id in the
  browser protocol yet; lifecycle needs a real VM to verify);
  P6 wiring + P12 migration marked done; production-readiness.md
  gains §20 (round-12 integration).
- Tests: new cloud/tests/test_chv_muse_round12.py, 24/24 OK;
  test_chv_muse_p8.py ledger corrupt test rewritten for the KV
  backend (+ legacy-import test). P7+P8: 95+1 tests OK.
This commit is contained in:
host committed 2026-11-07 00:36:25 +00:00
1 parent 466cf7eae7
commit d4cef29cdb
11 files changed
+1147 -63

No files matched your search

+25 -3
View File
@@ -547,15 +547,37 @@ class PresentationTestCase(unittest.TestCase):
with self.assertRaises(ValueError):
self.ledger.should_notify("t1", "", now=T0)
def test_ledger_corrupt_file_recovers(self):
def test_ledger_corrupt_row_recovers(self):
# round-12 迁移后:损坏的 KV 行回空账本(旧版是损坏的
# JSON 文件 quarantine 后回空;语义同款,fail-safe)。
self.assertTrue(self.ledger.should_notify("t1", "failed",
now=T0))
from cloud.chv.app_state import AppStateStore, \
host_state_db_path
with AppStateStore(host_state_db_path(self.state_dir)) as store:
store.kv_put("p8-ledger", "registry", "{not json")
self.assertTrue(self.ledger.should_notify("t2", "failed",
now=T0))
def test_ledger_legacy_json_imported_once(self):
# 旧版 JSON 文件首次加载时导入 KV 并改名 .migrated;
# 之后再写坏 JSON 也不影响 KV 里的账本。
with open(os.path.join(self.state_dir,
"notification-ledger.json"),
"w") as fh:
fh.write('{"notified": {"t0:failed": 1000}}')
self.assertFalse(self.ledger.should_notify(
"t0", "failed", now=1000 + 10))
self.assertTrue(os.path.exists(os.path.join(
self.state_dir, "notification-ledger.json.migrated")))
self.assertFalse(os.path.exists(os.path.join(
self.state_dir, "notification-ledger.json")))
with open(os.path.join(self.state_dir,
"notification-ledger.json"),
"w") as fh:
fh.write("{not json")
self.assertTrue(self.ledger.should_notify("t2", "failed",
now=T0))
self.assertFalse(self.ledger.should_notify(
"t0", "failed", now=1000 + 20))
def test_present_or_log_combined(self):
out = present_or_log({"status": "failed"}, self.ledger,
+528
View File
@@ -0,0 +1,528 @@
"""对齐 Muse round-12 集成测试(P4~P12 合入后的跨轨接线)。
只测本轨新增的接线,不复述各轨自有测试:
- AppStateStore KV API + audit_trail 表(迁移地基)。
- P8 三处登记 + P12 审计 JSONL 到 AppStateStore 的迁移
(语义不变:去重、哈希链、project_id 隔离;旧文件一次
性导入;损坏回空 fail-safe)。
- P6 worker → P3 helper/authd → git 的本地协议链(真
PrivsepClient + 真 worker server + 真 git status;runuser
与 HTTP 拉取需真机,见测试 docstring)。
- P12 审批卡 / 权限登记对真实 P3/P5 代码的 dict 契约
(不再用替身)。
"""
import importlib.util
import json
import os
import socket
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from cloud.chv.app_state import AppStateStore, host_state_db_path
from cloud.chv.audit_trail import (
AuditTrail,
StoreAuditTrail,
migrate_jsonl_to_store,
)
from cloud.chv.swarm import (
create_swarm,
get_swarm,
)
from cloud.chv.presentation import NotificationLedger
from cloud.chv.browser_tasks import (
BrowserTransport,
create_instance,
get_instance,
)
TOKEN = "fake-token-123"
T0 = 1700000000.0
def _load_guest_module(filename, module_name):
"""按文件加载 chv-guest 下的模块(不走包名,与 P3 测试同款)。"""
path = REPO_ROOT / "chv-guest" / filename
spec = importlib.util.spec_from_file_location(module_name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class KvApiTest(unittest.TestCase):
"""AppStateStore KV API(P8 迁移的后端)。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.store = AppStateStore(
os.path.join(self.dir, "app-state.sqlite"))
self.store.open()
def tearDown(self):
self.store.close()
self.tmp.cleanup()
def test_put_get_delete_roundtrip(self):
self.assertIsNone(self.store.kv_get("p8-swarm", "registry"))
self.store.kv_put("p8-swarm", "registry", '{"a": 1}')
self.assertEqual(self.store.kv_get("p8-swarm", "registry"),
'{"a": 1}')
self.store.kv_put("p8-swarm", "registry", '{"a": 2}')
self.assertEqual(self.store.kv_get("p8-swarm", "registry"),
'{"a": 2}')
self.store.kv_delete("p8-swarm", "registry")
self.assertIsNone(self.store.kv_get("p8-swarm", "registry"))
self.store.kv_delete("p8-swarm", "registry")
def test_namespaces_isolated(self):
self.store.kv_put("p8-swarm", "registry", "swarm-doc")
self.store.kv_put("p8-ledger", "registry", "ledger-doc")
self.assertEqual(self.store.kv_get("p8-swarm", "registry"),
"swarm-doc")
self.assertEqual(self.store.kv_get("p8-ledger", "registry"),
"ledger-doc")
def test_bad_namespace_or_key_rejected(self):
for ns, key in (("", "k"), ("a:b", "k"), ("ns", ""),
("ns", "a:b")):
with self.assertRaises(ValueError):
self.store.kv_put(ns, key, "v")
with self.assertRaises(ValueError):
self.store.kv_put("ns", "k", 123)
def test_host_path_differs_from_vm_path(self):
from cloud.chv.app_state import state_db_path
host = host_state_db_path(self.dir)
vm = state_db_path(self.dir, "chv-abc123")
self.assertNotEqual(host, vm)
self.assertTrue(host.endswith("app-state.sqlite"))
self.assertEqual(os.path.dirname(host), self.dir)
def test_counts_includes_audit_trail(self):
counts = self.store.counts()
self.assertIn("audit_trail", counts)
self.assertEqual(counts["audit_trail"], 0)
class StoreAuditTrailTest(unittest.TestCase):
"""StoreAuditTrail:与 JSONL 版同款链与隔离,走 SQLite。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
def tearDown(self):
self.tmp.cleanup()
def test_append_chain_resumes_across_instances(self):
first = StoreAuditTrail(self.dir)
r1 = first.append("p1", "worker", "task_dispatch", "run",
object_id="t1", ts=T0)
r2 = first.append("p1", "worker", "vm_lifecycle", "wake",
object_id="vm1", ts=T0 + 1)
self.assertEqual(r1["seq"], 1)
self.assertEqual(r2["seq"], 2)
self.assertEqual(r2["prev_hash"], r1["hash"])
second = StoreAuditTrail(self.dir)
r3 = second.append("p1", "worker", "task_dispatch", "done",
object_id="t1", ts=T0 + 2)
self.assertEqual(r3["seq"], 3)
self.assertEqual(r3["prev_hash"], r2["hash"])
ok, bad = second.verify()
self.assertTrue(ok)
self.assertEqual(bad, -1)
def test_project_isolation_and_filters(self):
trail = StoreAuditTrail(self.dir)
trail.append("p1", "worker", "task_dispatch", "run",
object_id="t1", ts=T0)
trail.append("p2", "worker", "task_dispatch", "run",
object_id="t2", ts=T0)
trail.append("p1", "worker", "quota", "denied",
object_id="t1", ts=T0 + 1)
self.assertEqual(len(trail.list_records("p1", limit=0)), 2)
self.assertEqual(len(trail.list_records("p2", limit=0)), 1)
self.assertEqual(trail.list_records("p3", limit=0), [])
self.assertEqual(trail.list_records("", limit=0), [])
self.assertEqual(
len(trail.list_records("p1", category="quota",
limit=0)), 1)
rows = trail.list_records("p1", limit=0)
self.assertGreater(rows[0]["ts"], rows[1]["ts"])
def test_tamper_detected_with_row_index(self):
trail = StoreAuditTrail(self.dir)
trail.append("p1", "worker", "task_dispatch", "run",
object_id="t1", ts=T0)
trail.append("p1", "worker", "task_dispatch", "done",
object_id="t1", ts=T0 + 1)
db = host_state_db_path(self.dir)
conn = sqlite3.connect(db)
conn.execute("UPDATE audit_trail SET action = ? WHERE seq = 2",
("forged",))
conn.commit()
conn.close()
ok, bad = StoreAuditTrail(self.dir).verify()
self.assertFalse(ok)
self.assertEqual(bad, 1)
def test_export_json(self):
trail = StoreAuditTrail(self.dir)
trail.append("p1", "worker", "task_dispatch", "run",
object_id="t1", ts=T0)
trail.append("p1", "worker", "task_dispatch", "done",
object_id="t1", ts=T0 + 1)
dest = os.path.join(self.dir, "export.json")
self.assertEqual(trail.export_json("p1", dest), 2)
self.assertEqual(os.stat(dest).st_mode & 0o777, 0o600)
with open(dest) as fh:
rows = json.load(fh)
self.assertEqual([r["seq"] for r in rows], [1, 2])
def test_secret_detail_scrubbed(self):
trail = StoreAuditTrail(self.dir)
rec = trail.append("p1", "worker", "task_dispatch", "run",
detail={"token": TOKEN, "note": "ok"},
ts=T0)
self.assertNotIn("token", rec["detail"])
self.assertEqual(rec["detail"]["note"], "ok")
class MigrateJsonlTest(unittest.TestCase):
"""migrate_jsonl_to_store:原样搬链。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.jsonl = os.path.join(self.dir, "audit.jsonl")
def tearDown(self):
self.tmp.cleanup()
def _seed_jsonl(self):
trail = AuditTrail(self.jsonl)
trail.append("p1", "worker", "task_dispatch", "run",
object_id="t1", ts=T0)
trail.append("p2", "worker", "quota", "denied",
object_id="t2", ts=T0 + 1)
trail.append("p1", "worker", "task_dispatch", "done",
object_id="t1", ts=T0 + 2)
return trail
def test_migrate_then_verify_and_list(self):
self._seed_jsonl()
self.assertEqual(migrate_jsonl_to_store(self.jsonl, self.dir),
3)
store_trail = StoreAuditTrail(self.dir)
ok, bad = store_trail.verify()
self.assertTrue(ok)
self.assertEqual(bad, -1)
self.assertEqual(len(store_trail.list_records("p1",
limit=0)), 2)
self.assertEqual(len(store_trail.list_records("p2",
limit=0)), 1)
rows = store_trail.list_records("p1", limit=0)
self.assertEqual([r["seq"] for r in rows], [3, 1])
def test_migrate_is_idempotent(self):
self._seed_jsonl()
self.assertEqual(migrate_jsonl_to_store(self.jsonl, self.dir),
3)
self.assertEqual(migrate_jsonl_to_store(self.jsonl, self.dir),
0)
ok, _ = StoreAuditTrail(self.dir).verify()
self.assertTrue(ok)
def test_missing_file_returns_zero(self):
self.assertEqual(
migrate_jsonl_to_store(os.path.join(self.dir, "nope"),
self.dir), 0)
def test_broken_chain_refused(self):
self._seed_jsonl()
with open(self.jsonl, "a") as fh:
fh.write("{broken json\n")
with self.assertRaises(ValueError):
migrate_jsonl_to_store(self.jsonl, self.dir)
self.assertEqual(
len(StoreAuditTrail(self.dir).list_records("p1",
limit=0)), 0)
def test_divergent_seq_refused(self):
self._seed_jsonl()
migrate_jsonl_to_store(self.jsonl, self.dir)
other = os.path.join(self.dir, "other.jsonl")
trail = AuditTrail(other)
trail.append("p1", "worker", "task_dispatch", "run",
object_id="tX", ts=T0)
with self.assertRaises(ValueError):
migrate_jsonl_to_store(other, self.dir)
class P8MigrationTest(unittest.TestCase):
"""P8 三处登记走 KV:无 JSON 文件、旧文件导入、损坏恢复。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
def tearDown(self):
self.tmp.cleanup()
def test_swarm_uses_kv_no_json_file(self):
swarm = create_swarm(self.dir, "p1", "parent1", now=T0)
self.assertFalse(os.path.exists(
os.path.join(self.dir, "swarms.json")))
self.assertTrue(os.path.exists(host_state_db_path(self.dir)))
with AppStateStore(host_state_db_path(self.dir)) as store:
raw = store.kv_get("p8-swarm", "registry")
self.assertIsNotNone(raw)
self.assertIn(swarm["id"], json.loads(raw)["swarms"])
self.assertEqual(get_swarm(self.dir, swarm["id"])["id"],
swarm["id"])
def test_swarm_legacy_json_imported_once(self):
legacy = {"swarms": {"s1": {"id": "s1", "project_id": "p1",
"parent_task_id": "parent1",
"agents": {}, "status": "active",
"created_at": T0}},
"tools": {}, "invocations": [], "audit": []}
with open(os.path.join(self.dir, "swarms.json"), "w") as fh:
json.dump(legacy, fh)
self.assertEqual(get_swarm(self.dir, "s1")["id"], "s1")
self.assertTrue(os.path.exists(
os.path.join(self.dir, "swarms.json.migrated")))
self.assertFalse(os.path.exists(
os.path.join(self.dir, "swarms.json")))
def test_swarm_corrupt_row_recovers(self):
create_swarm(self.dir, "p1", "parent1", now=T0)
with AppStateStore(host_state_db_path(self.dir)) as store:
store.kv_put("p8-swarm", "registry", "{not json")
swarm = create_swarm(self.dir, "p1", "parent2", now=T0)
self.assertEqual(swarm["parent_task_id"], "parent2")
def test_ledger_uses_kv_no_json_file(self):
ledger = NotificationLedger(self.dir)
self.assertTrue(ledger.should_notify("t1", "failed", now=T0))
self.assertFalse(os.path.exists(os.path.join(
self.dir, "notification-ledger.json")))
with AppStateStore(host_state_db_path(self.dir)) as store:
raw = store.kv_get("p8-ledger", "registry")
self.assertIn("t1:failed", json.loads(raw)["notified"])
def test_browser_tasks_legacy_import_and_corrupt_row(self):
legacy = {"instances": {
"old-task": {"instance_id": "i1", "task_id": "old-task",
"status": "destroyed",
"shots_dir": "browser-tasks/i1/shots",
"created_at": T0}}}
with open(os.path.join(self.dir, "browser-tasks.json"),
"w") as fh:
json.dump(legacy, fh)
self.assertEqual(
get_instance(self.dir, "old-task")["instance_id"], "i1")
self.assertTrue(os.path.exists(os.path.join(
self.dir, "browser-tasks.json.migrated")))
class _FakeTransport(BrowserTransport):
def spawn(self, instance):
return None
def close(self, instance):
return None
def ping(self, instance):
return True
with AppStateStore(host_state_db_path(self.dir)) as store:
store.kv_put("p8-browser-tasks", "registry", "{not json")
inst = create_instance(self.dir, "new-task",
_FakeTransport(), now=T0)
self.assertEqual(inst["status"], "active")
self.assertFalse(os.path.exists(os.path.join(
self.dir, "browser-tasks.json")))
class WorkerProtocolTest(unittest.TestCase):
"""P6 worker → P3 helper/authd → git 本地协议链。
真 PrivsepClient + 真 chv-gitea-worker server(Unix socket
+ SO_PEERCRED)+ 真 git status;run_git 探针调 worker 自身
的 ``_git_base_argv()``(含 P3 helper 与 useHttpPath)组
argv,只去掉本机不存在的 ``runuser -u chv`` 包装——runuser
与 HTTP 拉取(需真实 gitea.tose.top + Sentinel 放行)留
给真机轨,本测试不假装已覆盖。
"""
HELPER = REPO_ROOT / "chv-guest" / "git-credential-chv-authd"
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.workspace = os.path.join(self.dir, "workspace")
os.makedirs(self.workspace)
self.repo = os.path.join(self.workspace, "testrepo")
subprocess.run(["git", "init", "-q", "-b", "main", self.repo],
check=True)
subprocess.run(["git", "-C", self.repo, "config",
"user.email", "t@example.invalid"], check=True)
subprocess.run(["git", "-C", self.repo, "config",
"user.name", "Test"], check=True)
with open(os.path.join(self.repo, "README.md"), "w") as fh:
fh.write("hello\n")
subprocess.run(["git", "-C", self.repo, "add", "."],
check=True)
subprocess.run(["git", "-C", self.repo, "commit", "-q",
"-m", "init"], check=True)
self.socket_path = os.path.join(self.dir, "worker.sock")
self.log_path = os.path.join(self.dir, "worker.log")
self.mod = _load_guest_module("chv-gitea-worker.py",
"chv_gitea_worker_round12")
self.mod.HELPER_PATH = str(self.HELPER)
self.argv_seen = []
self.env_seen = []
def run_git(action, repo):
argv = ["runuser", "-u", self.mod.SYNC_USER, "--",
"env", "HOME=/home/chv"] + \
self.mod._git_base_argv()
if action == "status":
argv += ["-C", repo, "status", "-sb"]
elif action == "pull":
argv += ["-C", repo, "pull", "--ff-only"]
elif action == "push":
argv += ["-C", repo, "push"]
else:
return 2, "", "未知 git 操作:%s" % action
self.argv_seen.append(list(argv))
env = {"PATH": os.environ.get("PATH", ""),
"HOME": self.dir,
"CHV_AUTHD_SOCKET": ""}
self.env_seen.append(dict(env))
runnable = [a for a in argv
if a not in ("runuser", "-u", "chv", "--")]
runnable[1] = "HOME=%s" % self.dir
proc = subprocess.run(runnable, capture_output=True,
text=True, timeout=60,
env={**env, "HOME": self.dir})
return proc.returncode, proc.stdout, proc.stderr
self.server = self.mod.WorkerServer(
self.socket_path, self.workspace, self.log_path,
allowed_uids=(os.getuid(),), run_git=run_git,
helper_path=str(self.HELPER))
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.tmp.cleanup()
def test_status_through_real_client_and_server(self):
from cloud.chv.privsep import PrivsepClient, WorkerRequest
client = PrivsepClient(socket_path=self.socket_path,
connector="gitea",
workspace=self.workspace)
ping = client.ping()
self.assertTrue(ping.ok)
resp = client.call(WorkerRequest(
"status", "gitea", self.repo, "req-1"))
self.assertTrue(resp.ok, resp.stderr)
self.assertEqual(resp.returncode, 0)
self.assertIn("## main", resp.stdout)
self.assertEqual(resp.request_id, "req-1")
# worker 的 git argv 走 P3 helper + useHttpPath,不含 token:
self.assertEqual(len(self.argv_seen), 1)
argv = self.argv_seen[0]
self.assertIn("credential.helper=%s" % self.HELPER, argv)
self.assertIn("credential.useHttpPath=true", argv)
self.assertNotIn(TOKEN, " ".join(argv))
self.assertNotIn(TOKEN, json.dumps(self.env_seen))
# worker 用的 helper 就是 P3 的 helper 文件本身:
self.assertTrue(os.path.samefile(
self.server.helper_path, str(self.HELPER)))
# 审计日志不含 token(本测试的 token 只存在于断言字面量,
# 不进任何被测输入——worker 从未见过它):
with open(self.log_path) as fh:
self.assertNotIn(TOKEN, fh.read())
def test_helper_file_is_the_p3_helper(self):
# worker 硬编码的 helper 路径与 P3 测试用的 helper 是同一
# 个文件(接线不是替身)。
from cloud.tests.test_chv_muse_p3 import HelperE2ETest
self.assertTrue(os.path.samefile(
str(self.HELPER), str(HelperE2ETest.HELPER)))
class P12DictContractTest(unittest.TestCase):
"""P12 接线用真实 P3/P5 代码,不用替身。"""
def test_approval_card_from_real_p3_queue(self):
from cloud.chv.sentinel import ApprovalQueue, EgressRequest
from cloud.chv.approval_card import (
build_approval_card,
card_matches_channel,
)
from cloud.chv.approval_channel import request_approval
queue = ApprovalQueue(clock=lambda: T0)
req = EgressRequest("gitea", "POST", "https",
"gitea.tose.top", 443,
"/api/v1/repos/admin/x/issues")
pending_id = queue.record(req, ["needs_approval"])
record = queue.list_pending()[0]
self.assertEqual(record["pending_id"], pending_id)
card = build_approval_card(record)
self.assertEqual(card["pending_id"], pending_id)
self.assertEqual(card["target"]["host"], "gitea.tose.top")
self.assertEqual(card["target"]["method"], "POST")
self.assertEqual(card["risk"], "medium")
self.assertIn("needs_approval", card["reasons"])
channel = request_approval(record, secret="test-secret",
now=T0)
self.assertTrue(
card_matches_channel(card, channel.payload()))
def test_permission_registry_from_real_p5_describe(self):
from cloud.chv.capabilities import CapabilityIssuer
from cloud.chv.activity import PermissionRegistry
issuer = CapabilityIssuer(clock=lambda: T0)
cap = issuer.issue(
"one-shot", "gitea", "gitea.tose.top", ["GET"],
[("GET", "/api/v1/*", "allow")], "sync", now=T0)
described = cap.describe()
reg_path = os.path.join(
tempfile.mkdtemp(), "permissions.json")
reg = PermissionRegistry(reg_path)
grant = reg.record_grant("p1", described, ts=T0)
self.assertEqual(grant["capability_id"],
described["capability_id"])
self.assertEqual(grant["connector"], "gitea")
self.assertEqual(grant["host"], "gitea.tose.top")
self.assertEqual(grant["use_case"], "sync")
self.assertEqual(grant["tier"], "one-shot")
active = reg.list_active("p1", now=T0 + 10)
self.assertEqual(len(active), 1)
self.assertEqual(active[0]["capability_id"],
described["capability_id"])
self.assertTrue(reg.note_use("p1",
described["capability_id"]))
self.assertTrue(reg.revoke("p1",
described["capability_id"]))
self.assertEqual(reg.list_active("p1", now=T0 + 10), [])
if __name__ == "__main__":
unittest.main()