- 每 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 清单
183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
#!/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())
|