feat(chv): 对齐 Muse P7——状态与记忆分离
- 每 VM 一个 SQLite 状态库(cloud/chv/app_state.py): state_dir/<vm>/app-state.sqlite(0600,WAL),表 tasks/goals/ sessions/audit(只追加)/meta;写操作过无凭证守卫(键名 + 值模式);备份用 sqlite3 在线备份 + manifest(sha256),恢复 先验 manifest、先留 pre-restore 副本;DDL 用 SQLite 与 Postgres 共通受限子集,换 Postgres = 换连接工厂。 - 记忆文件接线(cloud/chv/memory_files.py):宿主镜像读/列/ 写/追加(路径穿越守卫、写审计);MemorySystemClient 调真实 记忆系统 CLI(search/get/explain/forget-plan/forget-confirm/ backup/restore/export),不重造引擎;forget = plan → confirm → 本地清行 → search 验证查不到;pull_memory_home 经 SSH + tar 拉取客机记忆家目录。 - 客内脚本 chv-guest/chv-memory.py(纯标准库,list/read/ append/forget/export),家目录 /home/chv/memory(workspace 之外,不同步到 Gitea);build.sh 拷入 + buildinfo 登记。 - 新测试 cloud/tests/test_chv_muse_p7.py 46 项全过(含真 CLI write/search/forget/backup/restore 实测、假 ssh 拉取、否定 测试);全量 cloud 套件 1913 项 OK(skipped=8)。 - 存储选型:SQLite 不是 Postgres(见差距表 §4;轻量 VM 跑 Postgres server 太重,宿主共享 Postgres 破坏每用户 VM 隔离);忘记是尽力删除 + 验证,不是密码学擦除。 - 真机待验证(未做,等测试机空闲):见 docs/chv-muse-p7-gap.md §6 清单
This commit is contained in:
1 parent
06c3e172fe
commit
29fa0c8813
8 files changed
+1664
-1
No files matched your search
@@ -166,6 +166,10 @@ if [ ! -f "$SCRIPT_DIR/chv-sync.sh" ]; then
|
||||
echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SCRIPT_DIR/chv-memory.py" ]; then
|
||||
echo "缺少 chv-memory.py(客内记忆文件脚本):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$DRY_RUN" = "0" ]; then
|
||||
if [ ! -e /dev/kvm ]; then
|
||||
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
||||
@@ -258,6 +262,8 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
||||
--copy-in "$REPO_DIR/host.py:/app/"
|
||||
--copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync'
|
||||
--copy-in "$SCRIPT_DIR/chv-memory.py:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/chv-memory.py /usr/local/bin/chv-memory && chmod +x /usr/local/bin/chv-memory'
|
||||
--run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg'
|
||||
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
||||
--run-command "$GT_INSTALL_CMD"
|
||||
|
||||
@@ -36,7 +36,7 @@ BASE_CONTENTS = [
|
||||
"python3", "python3-pip", "curl", "ca-certificates",
|
||||
"xz-utils", "fonts-wqy-microhei", "fontconfig", "codex",
|
||||
"codex-code-mode-host", "codex-resources/bwrap",
|
||||
"/app/host.py", "gt", "chv-sync",
|
||||
"/app/host.py", "gt", "chv-sync", "chv-memory",
|
||||
]
|
||||
|
||||
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""客内记忆文件操作(对齐 Muse P7,纯标准库)。
|
||||
|
||||
记忆家目录 /home/chv/memory(workspace 之外,不同步到 Gitea),
|
||||
布局与记忆系统同款:MEMORY.md + memory/*.md。由 executor 经
|
||||
SSH 调用(与 host.py 同级,跑在 nspawn cell 之外);绝不含凭证
|
||||
值——写操作过本地守卫,命中疑似凭证模式即拒绝。
|
||||
|
||||
用法:
|
||||
chv-memory.py list [--home DIR]
|
||||
chv-memory.py read <相对路径> [--home DIR]
|
||||
chv-memory.py append <相对路径> <行...> [--home DIR]
|
||||
chv-memory.py forget <needle> [--home DIR]
|
||||
chv-memory.py export <目标目录> [--home DIR]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
DEFAULT_HOME = "/home/chv/memory"
|
||||
|
||||
_SECRET_VALUE_PATTERNS = (
|
||||
re.compile(r"ghp_[A-Za-z0-9]{20,}"),
|
||||
re.compile(r"gho_[A-Za-z0-9]{20,}"),
|
||||
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
|
||||
re.compile(r"sk-[A-Za-z0-9]{20,}"),
|
||||
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
|
||||
re.compile(r"AKIA[0-9A-Z]{16}"),
|
||||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
def _fail(message: str) -> "NoReturn":
|
||||
"""打印错误到 stderr 并以 1 退出。"""
|
||||
print("chv-memory: %s" % message, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _check_no_secret(text: str) -> None:
|
||||
"""文本命中凭证模式即拒(不打印命中内容)。"""
|
||||
for pattern in _SECRET_VALUE_PATTERNS:
|
||||
if pattern.search(text):
|
||||
_fail("refused: text looks like a credential")
|
||||
|
||||
|
||||
def _resolve(home: str, rel: str) -> str:
|
||||
"""相对路径 → 家目录内绝对路径,穿越即拒。"""
|
||||
base = os.path.abspath(home)
|
||||
candidate = os.path.abspath(os.path.join(base, rel))
|
||||
if candidate != base and not candidate.startswith(base + os.sep):
|
||||
_fail("path escapes memory home: %s" % rel)
|
||||
return candidate
|
||||
|
||||
|
||||
def _ensure(home: str) -> None:
|
||||
"""建家目录结构(MEMORY.md + memory/)。"""
|
||||
os.makedirs(os.path.join(home, "memory"), exist_ok=True)
|
||||
memory_md = os.path.join(home, "MEMORY.md")
|
||||
if not os.path.exists(memory_md):
|
||||
with open(memory_md, "w", encoding="utf-8") as handle:
|
||||
handle.write("# MEMORY.md\n")
|
||||
|
||||
|
||||
def cmd_list(home: str) -> dict:
|
||||
"""列家目录下全部文件(相对路径,排序)。"""
|
||||
_ensure(home)
|
||||
found = []
|
||||
for root, _dirs, names in os.walk(home):
|
||||
for name in names:
|
||||
found.append(os.path.relpath(os.path.join(root, name), home))
|
||||
return {"files": sorted(found)}
|
||||
|
||||
|
||||
def cmd_read(home: str, rel: str) -> dict:
|
||||
"""读一个记忆文件。"""
|
||||
_ensure(home)
|
||||
path = _resolve(home, rel)
|
||||
if not os.path.isfile(path):
|
||||
_fail("memory file missing: %s" % rel)
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return {"path": rel, "text": handle.read()}
|
||||
|
||||
|
||||
def cmd_append(home: str, rel: str, lines: list) -> dict:
|
||||
"""向一个记忆文件追加行。"""
|
||||
_ensure(home)
|
||||
for line in lines:
|
||||
_check_no_secret(line)
|
||||
path = _resolve(home, rel)
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as handle:
|
||||
for line in lines:
|
||||
handle.write(line if line.endswith("\n") else line + "\n")
|
||||
return {"path": rel, "appended": len(lines)}
|
||||
|
||||
|
||||
def cmd_forget(home: str, needle: str) -> dict:
|
||||
"""删掉全部 Markdown 里含 needle 的行(大小写不敏感)。"""
|
||||
_ensure(home)
|
||||
removed = 0
|
||||
touched = []
|
||||
for root, _dirs, names in os.walk(home):
|
||||
for name in names:
|
||||
if not name.endswith(".md"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.readlines()
|
||||
kept = [ln for ln in lines
|
||||
if needle.lower() not in ln.lower()]
|
||||
cut = len(lines) - len(kept)
|
||||
if cut:
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.writelines(kept)
|
||||
removed += cut
|
||||
touched.append(os.path.relpath(path, home))
|
||||
return {"needle": needle, "removed": removed, "files": touched}
|
||||
|
||||
|
||||
def cmd_export(home: str, dest: str) -> dict:
|
||||
"""导出全部记忆文件到目标目录(覆盖式,不含审计日志)。"""
|
||||
_ensure(home)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
count = 0
|
||||
for root, _dirs, names in os.walk(home):
|
||||
for name in names:
|
||||
if name == "audit.jsonl":
|
||||
continue
|
||||
src = os.path.join(root, name)
|
||||
rel = os.path.relpath(src, home)
|
||||
target = os.path.join(dest, rel)
|
||||
os.makedirs(os.path.dirname(target) or dest, exist_ok=True)
|
||||
shutil.copyfile(src, target)
|
||||
count += 1
|
||||
return {"dest": dest, "files": count}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""命令行解析器。"""
|
||||
parser = argparse.ArgumentParser(prog="chv-memory.py")
|
||||
parser.add_argument("--home", default=DEFAULT_HOME)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("list")
|
||||
read_p = sub.add_parser("read")
|
||||
read_p.add_argument("path")
|
||||
append_p = sub.add_parser("append")
|
||||
append_p.add_argument("path")
|
||||
append_p.add_argument("lines", nargs="+")
|
||||
forget_p = sub.add_parser("forget")
|
||||
forget_p.add_argument("needle")
|
||||
export_p = sub.add_parser("export")
|
||||
export_p.add_argument("dest")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list | None = None) -> int:
|
||||
"""入口:按子命令分发,打印 JSON 结果。"""
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.cmd == "list":
|
||||
result = cmd_list(args.home)
|
||||
elif args.cmd == "read":
|
||||
result = cmd_read(args.home, args.path)
|
||||
elif args.cmd == "append":
|
||||
result = cmd_append(args.home, args.path, args.lines)
|
||||
elif args.cmd == "forget":
|
||||
result = cmd_forget(args.home, args.needle)
|
||||
elif args.cmd == "export":
|
||||
result = cmd_export(args.home, args.dest)
|
||||
else: # pragma: no cover - argparse required=True 已挡住
|
||||
_fail("unknown command: %s" % args.cmd)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user