"""Repair commits whose server-stored signature/signer_public_key no longer verify, by replaying the correct values from local Muse history. Root cause (muse#108 wire-protocol investigation, continued from the structured_delta clone-loss fix): the rc10 object-store migration stamped a spurious signer_public_key *and signature* onto historical commit rows without recomputing commit_id (see wire_repair_commit's docstring in musehub_wire_push.py). The repair mechanism (POST /{owner}/{slug}/repair-commit) already exists and already works correctly -- MWP-8 shipped its cache invalidation. What never happened is actually running it against the full set of affected rows; only two commits in gabriel/muse were ever manually repaired. This script does that, for any repo, driven by `muse verify`'s own failure list. Usage (run from the host, not inside Docker -- needs the local muse CLI and a registered identity with write/admin access to the target repo): python3 deploy/repair_commit_signatures.py \\ --repo ~/ecosystem/musehub --url https://staging.musehub.ai/gabriel/musehub \\ --hub https://staging.musehub.ai --dry-run python3 deploy/repair_commit_signatures.py \\ --repo ~/ecosystem/musehub --url https://staging.musehub.ai/gabriel/musehub \\ --hub https://staging.musehub.ai """ from __future__ import annotations import argparse import json import subprocess import sys import time # Keys present in `muse read --json` output that are never part of a # WireCommit repair payload -- either envelope fields (identical on every # muse command) or read-only diff-summary fields computed from the commit's # relationship to its parent, not stored commit identity. _ENVELOPE_KEYS = frozenset({ "muse_version", "schema", "exit_code", "duration_ms", "timestamp", "warnings", }) _DIFF_SUMMARY_KEYS = frozenset({ "status", "files_added", "files_removed", "files_modified", "dirs_added", "dirs_removed", "total_changes", }) # Every WireCommit field typed as plain `str = ""` (never `str | None`). # `muse read` reports an unset optional provenance field as null, but # WireCommit's validator rejects None outright for these -- "Input should # be a valid string" -- which is exactly the 422 seen live when the first # version of this list only covered toolchain_id/prompt_hash and missed # agent_id/model_id on human (non-agent) commits. Keep this exhaustive # against musehub/models/wire.py::WireCommit, not a partial guess -- # snapshot_id, parent_commit_id, and parent2_commit_id are deliberately # excluded: those ARE `str | None` in WireCommit, so None is the correct, # valid value for them and must not be coerced. _NULLABLE_STRING_FIELDS = ( "repo_id", "branch", "message", "committed_at", "author", "sem_ver_bump", "agent_id", "model_id", "toolchain_id", "prompt_hash", "signature", "signer_public_key", "signer_key_id", ) def project_to_wire_commit_fields(muse_read_record: dict) -> dict: """Project a `muse read --json` record to exactly the WireCommit-shaped subset needed for a repair-commit request body. Pure function -- no I/O, no network, fully unit-testable. """ result = { k: v for k, v in muse_read_record.items() if k not in _ENVELOPE_KEYS and k not in _DIFF_SUMMARY_KEYS } for field in _NULLABLE_STRING_FIELDS: if result.get(field) is None: result[field] = "" return result def muse_read(repo: str, commit_id: str) -> dict: result = subprocess.run( ["muse", "-C", repo, "read", commit_id, "--json"], capture_output=True, text=True, check=True, ) return json.loads(result.stdout) def muse_verify_failures(repo: str) -> list[dict]: """Return the `failures` list from `muse verify --json` for *repo*.""" result = subprocess.run( ["muse", "-C", repo, "verify", "--json"], capture_output=True, text=True, ) data = json.loads(result.stdout) return [f for f in data.get("failures", []) if f.get("kind") == "signature"] def repair_one(url: str, hub: str, payload: dict, *, dry_run: bool) -> tuple[bool, str]: """Call repair-commit for one commit. Returns (ok, message).""" if dry_run: return True, "dry-run, not sent" body = json.dumps({"commit": payload}) result = subprocess.run( [ "muse", "sign", "request", "--method", "POST", "--url", f"{url.rstrip('/')}/repair-commit", "--hub", hub, "--body", body, "--json", ], capture_output=True, text=True, ) if result.returncode != 0: return False, result.stderr.strip() or result.stdout.strip() return True, result.stdout.strip() def run( repo: str, verify_repo: str, url: str, hub: str, *, dry_run: bool, limit: int | None, offset: int = 0, ) -> None: """*repo* is the trusted, canonical source of correct data (`muse read`). *verify_repo* is a fresh clone reflecting the server's actual (possibly corrupted) state -- `muse verify` must run there, never against *repo*, since the canonical copy is correct by definition and would always report zero failures. *offset* lets repeated runs advance through one verify-repo clone's failure list without re-cloning between batches (re-cloning + muse verify takes ~15-25s and isn't needed every batch -- repair-commit is idempotent, so even re-targeting an already-fixed commit from a stale list is harmless, just wasted work). Re-clone and restart from offset=0 periodically to pick up the true remaining count and catch anything a concurrent write raced past this verify-repo's snapshot.""" print(f"Scanning {verify_repo} (server-state clone) for signature-verification failures…") failures = muse_verify_failures(verify_repo) print(f"Found {len(failures)} signature failures.") if offset: failures = failures[offset:] print(f"Skipping first {offset} (already processed in a prior batch against this clone).") if limit is not None: failures = failures[:limit] print(f"Limiting to {limit} for this run.") repaired = 0 errors = 0 t0 = time.monotonic() for i, failure in enumerate(failures, 1): commit_id = failure["id"] try: record = muse_read(repo, commit_id) except subprocess.CalledProcessError as exc: print(f" ✗ {commit_id[:20]}… muse read failed: {exc.stderr.strip()}", file=sys.stderr) errors += 1 continue payload = project_to_wire_commit_fields(record) ok, message = repair_one(url, hub, payload, dry_run=dry_run) if ok: repaired += 1 tag = "[DRY RUN] " if dry_run else "" print(f" {tag}✓ [{i}/{len(failures)}] {commit_id[:20]}…") else: errors += 1 print(f" ✗ [{i}/{len(failures)}] {commit_id[:20]}… {message}", file=sys.stderr) elapsed = time.monotonic() - t0 tag = "[DRY RUN] " if dry_run else "" print(f"\n{tag}Done in {elapsed:.1f}s: repaired={repaired} errors={errors}") def main() -> None: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--repo", required=True, help="Path to the trusted, canonical local Muse repo (e.g. ~/ecosystem/musehub) -- source of correct data for muse read") parser.add_argument("--verify-repo", required=True, help="Path to a FRESH CLONE reflecting the server's actual state -- muse verify runs here, never against --repo") parser.add_argument("--url", required=True, help="Full repo URL on the hub (e.g. https://staging.musehub.ai/gabriel/musehub)") parser.add_argument("--hub", required=True, help="Hub base URL, determines which signing key to use") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--limit", type=int, default=None, help="Repair at most N commits this run") parser.add_argument("--offset", type=int, default=0, help="Skip the first N failures from --verify-repo's list (advance through one clone across batches without re-cloning)") args = parser.parse_args() run(args.repo, args.verify_repo, args.url, args.hub, dry_run=args.dry_run, limit=args.limit, offset=args.offset) if __name__ == "__main__": main()