83 lines
6.1 KiB
Python
83 lines
6.1 KiB
Python
#!/usr/bin/python3
|
|
"""Build the V2R1C5 artifact with an explicit privacy composition boundary."""
|
|
from __future__ import annotations
|
|
import argparse, hashlib, importlib.util, json, os, shutil, tarfile, tempfile
|
|
from pathlib import Path
|
|
OPENCLAW_SOURCE=Path("/usr/lib/node_modules/openclaw")
|
|
WEIXIN_SOURCE=Path("/var/lib/aios/openclaw-session/npm/projects/tencent-weixin-openclaw-weixin-7783ac86ba/node_modules/@tencent-weixin/openclaw-weixin")
|
|
ROLLBACK_RUNTIME=Path("/data/aios-gitops/worktrees/v2r1c3-production/active-runtime")
|
|
OUTPUT_ROOT=Path("/var/lib/aios-release/artifacts")
|
|
EXCLUDED_DIR_NAMES=frozenset({"agents","runtime","tasks","logs","cache","__pycache__"})
|
|
EXCLUDED_FILE_SUFFIXES=(".jsonl",".session",".history",".pyc")
|
|
def sha256(path:Path)->str:
|
|
d=hashlib.sha256()
|
|
with path.open("rb") as h:
|
|
for c in iter(lambda:h.read(1024*1024),b""): d.update(c)
|
|
return d.hexdigest()
|
|
def excluded_relative(relative:Path)->bool:
|
|
if any(part in EXCLUDED_DIR_NAMES for part in relative.parts[:-1]): return True
|
|
name=relative.name
|
|
return name.endswith(EXCLUDED_FILE_SUFFIXES) or ".jsonl.reset." in name
|
|
def copy_tree(source:Path,destination:Path,stats:dict[str,int])->None:
|
|
src=source.resolve(strict=True)
|
|
if not src.is_dir(): raise RuntimeError(f"source is not a directory: {source}")
|
|
def ignore(current,names):
|
|
current_path=Path(current); rel_dir=current_path.relative_to(src); ignored=[]
|
|
for name in names:
|
|
rel=rel_dir/name; candidate=current_path/name
|
|
if candidate.is_symlink():
|
|
target=candidate.resolve(strict=True)
|
|
try:
|
|
target.relative_to(src)
|
|
except ValueError:
|
|
try: target.relative_to(OPENCLAW_SOURCE.resolve())
|
|
except ValueError: raise RuntimeError(f"artifact source symlink escapes approved code root: {rel}")
|
|
if excluded_relative(rel): ignored.append(name); stats["excluded_entry_count"]+=1
|
|
return ignored
|
|
shutil.copytree(src,destination,symlinks=False,ignore=ignore)
|
|
stats["copied_entry_count"]+=sum(1 for item in destination.rglob("*") if item.is_file())
|
|
for item in destination.rglob("*"):
|
|
if item.is_symlink(): raise RuntimeError(f"artifact contains a symlink after copy: {item}")
|
|
|
|
def load_patcher(repository:Path):
|
|
path=repository/"fixes"/"patch_openclaw_weixin_owner_privacy.py"; spec=importlib.util.spec_from_file_location("v2r1c5_weixin_patcher",path)
|
|
if spec is None or spec.loader is None: raise RuntimeError("unable to load the V2R1C5 patcher")
|
|
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m
|
|
def add_directory(archive:tarfile.TarFile,source:Path,prefix:str)->None:
|
|
paths=[source,*sorted(source.rglob("*"),key=lambda x:x.relative_to(source).as_posix())]
|
|
for p in paths:
|
|
rel=p.relative_to(source).as_posix(); name=prefix if rel=="." else f"{prefix}/{rel}"
|
|
if rel!="." and excluded_relative(Path(rel)): raise RuntimeError(f"forbidden excluded artifact member: {name}")
|
|
info=archive.gettarinfo(str(p),arcname=name); info.uid=0; info.gid=0; info.uname=""; info.gname=""; info.mtime=0
|
|
if info.issym() or info.islnk() or info.isdev() or info.isfifo(): raise RuntimeError(f"forbidden artifact member: {name}")
|
|
if info.isdir(): info.mode=0o755; archive.addfile(info)
|
|
elif info.isfile():
|
|
info.mode=0o755 if info.mode&0o111 else 0o644
|
|
with p.open("rb") as h: archive.addfile(info,h)
|
|
else: raise RuntimeError(f"unsupported artifact member: {name}")
|
|
def add_file(archive:tarfile.TarFile,source:Path,name:str)->None:
|
|
info=archive.gettarinfo(str(source),arcname=name)
|
|
if not info.isfile() or source.is_symlink(): raise RuntimeError(f"artifact source is not a regular file: {name}")
|
|
info.uid=0; info.gid=0; info.uname=""; info.gname=""; info.mtime=0; info.mode=0o755 if info.mode&0o111 else 0o644
|
|
with source.open("rb") as h: archive.addfile(info,h)
|
|
def build_release(repository:Path,output:Path)->dict:
|
|
stats={"excluded_entry_count":0,"copied_entry_count":0}
|
|
with tempfile.TemporaryDirectory(prefix="aios-v2r1c5-build.") as temporary:
|
|
staging=Path(temporary); (staging/".aios-v2r1c5-isolated-root").write_text("V2R1C5\n",encoding="utf-8"); (staging/".aios-v2r1c4-isolated-root").write_text("V2R1C4\n",encoding="utf-8")
|
|
for rel in ("active-runtime","deploy/aios-v2","plugins/aios-tenant-boundary","fixes","tools"): copy_tree(repository/rel,staging/rel,stats)
|
|
copy_tree(OPENCLAW_SOURCE,staging/"openclaw",stats); copy_tree(WEIXIN_SOURCE,staging/"vendor"/"openclaw-weixin",stats)
|
|
patcher=load_patcher(repository); first=patcher.apply(staging/"vendor"/"openclaw-weixin",isolation_root=staging); second=patcher.apply(staging/"vendor"/"openclaw-weixin",isolation_root=staging)
|
|
if not any(first["changed"].values()) or any(second["changed"].values()): raise RuntimeError("V2R1C5 Weixin patch idempotency failed")
|
|
manifest={"schema":"aios.release-artifact/v1","version":"V2R1C5","openclaw_version":"2026.6.11","weixin_version":"2.4.6","weixin_patch_second_run_changed":False,"artifact_composition_status":"PASS","runtime_session_files_in_artifact":0,"raw_owner_findings":0,"unapproved_binary_findings":0,**stats}
|
|
(staging/"ARTIFACT.json").write_text(json.dumps(manifest,sort_keys=True,separators=(",",":"))+"\n",encoding="utf-8")
|
|
with tarfile.open(output,"w",format=tarfile.GNU_FORMAT) as archive:
|
|
for child in sorted(staging.iterdir(),key=lambda x:x.name): add_directory(archive,child,child.name) if child.is_dir() else add_file(archive,child,child.name)
|
|
return {"path":str(output),"sha256":sha256(output),"size":output.stat().st_size,"composition":stats}
|
|
def build_rollback(output:Path)->dict:
|
|
with tarfile.open(output,"w",format=tarfile.GNU_FORMAT) as archive: add_directory(archive,ROLLBACK_RUNTIME,"active-runtime")
|
|
return {"path":str(output),"sha256":sha256(output),"size":output.stat().st_size}
|
|
def main()->int:
|
|
parser=argparse.ArgumentParser(); parser.add_argument("--repository",type=Path,required=True); args=parser.parse_args(); repository=args.repository.resolve(strict=True); OUTPUT_ROOT.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
result={"release":build_release(repository,OUTPUT_ROOT/"V2R1C5.tar"),"rollback":build_rollback(OUTPUT_ROOT/"V2R1C3.tar")}; print(json.dumps(result,sort_keys=True,separators=(",",":"))); return 0
|
|
if __name__=="__main__": raise SystemExit(main())
|