#!/usr/bin/env python3 """migrate_api_keys.py — B-APIKEY-MIGRATION (wave-apikey-migration). Port ALL API keys and saved connections from mimo code into rune, lossless, same semantics, respected exactly (docs/factory/spawn-queue.md#B-APIKEY-MIGRATION; approval_ref 'user:wave-apikey-migration dispatch'). Scans the mimo/opencode config stores, extracts provider/key/model/endpoint pairs, maps them onto rune's provider format, and lands the credentials in rune's secret store (OS keyring / Secret Service / encrypted file — never plaintext in the config, the UI, logs, or this script's output). Every artifact this script writes speaks in fingerprints only. Sources (per machine, relative to $HOME): ~/.config/mimocode/mimocode.jsonc provider configs (inline apiKeys) ~/.local/share/mimocode/auth.json mimo auth store (api + oauth) ~/.config/opencode/opencode.json|c legacy opencode provider configs ~/.local/share/opencode/auth.json legacy opencode auth store ~/.config/opencode/secrets/* generic saved secrets (gitea-token…) ~/.rune/model_state.json saved model selection (metadata) Frozen battery: tests/test_apikey_migration.py (red-first round trip). """ from __future__ import annotations import argparse import json import os import sys from datetime import datetime, timezone REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if REPO not in sys.path: sys.path.insert(0, REPO) from rune import secret_store as ss # noqa: E402 MIGRATION_SOURCE = "mimo-migration" # ═════════════════════════════════════════════════════════════════════ # parsing # ═════════════════════════════════════════════════════════════════════ def parse_jsonc(text: str): """JSONC: strip // and /* */ comments (string-aware) and trailing commas.""" out, in_str, esc, i = [], False, False, 0 n = len(text) while i < n: c = text[i] if in_str: out.append(c) if esc: esc = False elif c == "\\": esc = True elif c == '"': in_str = False i += 1 continue if c == '"': in_str = True out.append(c) i += 1 elif c == "/" and i + 1 < n and text[i + 1] == "/": while i < n and text[i] != "\n": i += 1 elif c == "/" and i + 1 < n and text[i + 1] == "*": i += 2 while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): i += 1 i += 2 else: out.append(c) i += 1 clean = "".join(out) import re clean = re.sub(r",(\s*[}\]])", r"\1", clean) return json.loads(clean) def _read_json(path): with open(path) as f: text = f.read() try: return json.loads(text) except ValueError: return parse_jsonc(text) # ═════════════════════════════════════════════════════════════════════ # sources # ═════════════════════════════════════════════════════════════════════ class Sources: """Paths of the mimo-world stores. home is injectable for frozen tests.""" def __init__(self, home=None): self.home = home or os.path.expanduser("~") self.mimocode_config = os.path.join(self.home, ".config/mimocode/mimocode.jsonc") self.mimo_auth = os.path.join(self.home, ".local/share/mimocode/auth.json") self.opencode_config = os.path.join(self.home, ".config/opencode/opencode.json") self.opencode_config_c = os.path.join(self.home, ".config/opencode/opencode.jsonc") self.opencode_auth = os.path.join(self.home, ".local/share/opencode/auth.json") self.opencode_secrets_dir = os.path.join(self.home, ".config/opencode/secrets") self.model_state = os.path.join(self.home, ".rune/model_state.json") def _load_if_exists(path): if path and os.path.exists(path): return _read_json(path) return {} def scan_mimocode_jsonc(path): data = _load_if_exists(path) return { "providers": data.get("provider", {}), "model": data.get("model"), "model_groups": data.get("model_groups"), } def scan_auth_json(path, origin): data = _load_if_exists(path) entries = {} for pid, rec in data.items(): if not isinstance(rec, dict): continue rec = dict(rec) rec["_origin"] = origin entries[pid] = rec return {"entries": entries} def scan_opencode_json(path): data = _load_if_exists(path) return {"providers": data.get("provider", {})} def scan_secrets_dir(path): out = {} if path and os.path.isdir(path): for name in sorted(os.listdir(path)): full = os.path.join(path, name) if os.path.isfile(full): with open(full) as f: out[name] = f.read().rstrip("\n") return {"secrets": out} def scan_model_state(path): return _load_if_exists(path) # ═════════════════════════════════════════════════════════════════════ # collect — merge stores into a migration plan # ═════════════════════════════════════════════════════════════════════ def _normalize_url(url, provider_type): """Rune's registry appends /v1/... itself; a mimo baseURL keeps its /v1 only as source_base_url (lossless), while the rune url drops it.""" if not url: return None u = url.rstrip("/") if u.endswith("/v1"): u = u[:-3].rstrip("/") return u or None def _provider_type(npm, pid): if pid == "ollama" or npm == "@ai-sdk/ollama": return "ollama" if npm == "@ai-sdk/anthropic": return "anthropic" return "openai" def _is_url_placeholder(key): return isinstance(key, str) and key.startswith(("http://", "https://")) def _is_dummy_key(material, provider_id): """Local servers get literal dummy bearers ('ollama', the tags URL…). Rune's ollama type sends no auth — these are connection notes, not credentials.""" return (isinstance(material, str) and material.strip().lower() == str(provider_id).strip().lower()) def collect(sources: Sources): """Merge every store into a plan: providers (id → endpoint/models/keys) and distinct secret entries (deduped by material+endpoint, aliases kept).""" plan = { "providers": [], "secrets": [], "skipped": [], "model_selection": scan_model_state(sources.model_state) or {}, } # provider records, in scan order providers: dict[str, dict] = {} def touch(pid, *, display=None, npm=None, base_url=None, inline_key=None, models=None, origin=None): rec = providers.setdefault(pid, {"id": pid, "display_name": None, "npm": None, "base_url": None, "inline_key": None, "models": [], "origins": [], "auth": {}}) if display and not rec["display_name"]: rec["display_name"] = display if npm and not rec["npm"]: rec["npm"] = npm if base_url and not rec["base_url"]: rec["base_url"] = base_url if inline_key and not rec["inline_key"]: rec["inline_key"] = inline_key if models: seen = {m["name"] for m in rec["models"]} for m in models: if m["name"] not in seen: rec["models"].append(m) if origin and origin not in rec["origins"]: rec["origins"].append(origin) return rec for path, origin in ((sources.mimocode_config, "mimocode.jsonc"), (sources.opencode_config, "opencode.json"), (sources.opencode_config_c, "opencode.jsonc")): for pid, cfg in scan_opencode_json(path)["providers"].items(): if not isinstance(cfg, dict): continue opts = cfg.get("options", {}) models = [] for mname, m in (cfg.get("models") or {}).items(): if isinstance(m, dict): models.append({"name": mname, "display_name": m.get("name", mname), "limit": m.get("limit")}) touch(pid, display=cfg.get("name"), npm=cfg.get("npm"), base_url=opts.get("baseURL"), inline_key=opts.get("apiKey"), models=models, origin=origin) auth_by_origin = {} for path, origin in ((sources.mimo_auth, "mimo-auth.json"), (sources.opencode_auth, "opencode-auth.json")): entries = scan_auth_json(path, origin)["entries"] auth_by_origin[origin] = entries for pid, rec in entries.items(): touch(pid, base_url=(rec.get("metadata") or {}).get("base_url"), origin=origin) providers[pid]["auth"][origin] = rec # credential dedupe: (material, endpoint) → one secret entry secrets: dict[tuple, dict] = {} skipped = [] used_refs: set[str] = set() def unique_ref(base): ref = base n = 2 while ref in used_refs: ref = f"{base}#{n}" n += 1 used_refs.add(ref) return ref def add_secret(material, kind, provider_id, origin, active, base_ref=None): if _is_url_placeholder(material): skipped.append({"provider": provider_id, "origin": origin, "reason": f"{provider_id}: URL-shaped placeholder in auth " "store is not a credential"}) return None if _is_dummy_key(material, provider_id): skipped.append({"provider": provider_id, "origin": origin, "reason": f"{provider_id}: provider-id-shaped dummy bearer " "in config is not a credential"}) return None endpoint = providers.get(provider_id, {}).get("base_url") or "" key = (material, endpoint) entry = secrets.get(key) if entry is None: ref = unique_ref(base_ref or f"provider/{provider_id}") entry = {"ref": ref, "material": material, "kind": kind, "owner": provider_id, "aliases": [provider_id], "active": active, "origins": [origin]} secrets[key] = entry else: if provider_id not in entry["aliases"]: entry["aliases"].append(provider_id) if origin not in entry["origins"]: entry["origins"].append(origin) if active: entry["active"] = True return entry # precedence: inline config key > mimo auth > opencode auth for pid, rec in providers.items(): origin_rank = {"mimocode.jsonc": 0, "mimo-auth.json": 1, "opencode-auth.json": 2} auth_keys = [] # (origin, key, kind) for origin in sorted(rec["auth"], key=lambda o: origin_rank.get(o, 9)): a = rec["auth"][origin] if a.get("type") == "oauth": bundle = {"kind": "oauth", "access": a.get("access", ""), "refresh": a.get("refresh", ""), "expires": a.get("expires"), "account_id": a.get("accountId")} e = add_secret(json.dumps(bundle, sort_keys=True), "oauth", pid, origin, active=True) if e: e["kind"] = "oauth" rec["oauth_ref"] = e["ref"] if not rec.get("active_ref"): rec["active_ref"] = e["ref"] continue k = a.get("key") if k: auth_keys.append((origin, k)) order = ["mimocode.jsonc", "mimo-auth.json", "opencode-auth.json"] auth_keys.sort(key=lambda ok: order.index(ok[0]) if ok[0] in order else 9) # inline config key is active; auth keys follow as alternates if rec["inline_key"]: e = add_secret(rec["inline_key"], "api", pid, rec["origins"][0], active=True) if e: e["kind"] = "api" rec["active_ref"] = e["ref"] alt_idx = 0 for origin, k in auth_keys: if rec["inline_key"] and k == rec["inline_key"]: continue # same material already stored active is_active = not rec["inline_key"] and not rec.get("active_ref") e = add_secret(k, "api", pid, origin, active=is_active, base_ref=f"provider/{pid}#alt{alt_idx}" if not is_active else None) alt_idx += 1 if e: e["kind"] = "api" if is_active: rec["active_ref"] = e["ref"] elif e["ref"] not in rec.setdefault("alt_key_refs", []): rec["alt_key_refs"].append(e["ref"]) # generic secrets (gitea-token and friends) for name, value in scan_secrets_dir(sources.opencode_secrets_dir)["secrets"].items(): ref = unique_ref(f"secret/{name}") entry = {"ref": ref, "material": value, "kind": "secret", "owner": name, "aliases": [name], "active": True, "origins": ["opencode-secrets"]} secrets[(value, "")] = entry # assemble provider plan entries for pid, rec in providers.items(): ptype = _provider_type(rec["npm"], pid) url = _normalize_url(rec["base_url"], ptype) if ptype == "openai" else rec["base_url"] if ptype == "ollama" and rec["base_url"]: url = _normalize_url(rec["base_url"], ptype) entry = { "id": pid, "display_name": rec["display_name"] or pid, "provider_type": ptype, "url": url, "source_base_url": rec["base_url"], "models": rec["models"], "enabled": True, "endpoint_unknown": rec["base_url"] is None, } if rec.get("active_ref"): entry["api_key_ref"] = rec["active_ref"] entry["auth_kind"] = "oauth" if rec.get("oauth_ref") == rec["active_ref"] else "api" if rec.get("oauth_ref") and rec.get("active_ref") != rec.get("oauth_ref"): entry["oauth_ref"] = rec["oauth_ref"] if rec.get("alt_key_refs"): entry["alt_key_refs"] = sorted(set(rec["alt_key_refs"])) plan["providers"].append(entry) plan["providers"].sort(key=lambda p: p["id"]) plan["secrets"] = sorted(secrets.values(), key=lambda s: s["ref"]) plan["skipped"] = skipped return plan # ═════════════════════════════════════════════════════════════════════ # apply + verify # ═════════════════════════════════════════════════════════════════════ def _load_rune_config(config_path): config_path = str(config_path) if os.path.exists(config_path): with open(config_path) as f: return json.load(f) return {"providers": {}} def apply_migration(plan, config_path, store): """Land the plan into rune: secrets into the store, provider entries (refs only) into the rune provider config. Idempotent.""" config_path = str(config_path) cfg = _load_rune_config(config_path) providers = cfg.setdefault("providers", {}) stored = [] for s in plan["secrets"]: store.put(s["ref"], s["material"]) stored.append({ "ref": s["ref"], "fingerprint": ss.fingerprint(s["material"]), "kind": s["kind"], "provider": s["owner"], "aliases": list(s["aliases"]), "active": s["active"], "alt_refs": list(s.get("alt_refs", [])), }) applied = 0 for p in plan["providers"]: pid = p["id"] existing = providers.get(pid) if existing is None: entry = {"url": p["url"], "type": p["provider_type"], "enabled": True} applied += 1 else: entry = dict(existing) if existing.get("url") is None or existing.get("url") == "": entry["url"] = p["url"] entry.setdefault("type", p["provider_type"]) migrated_models = {m["name"]: m for m in p["models"]} merged = {m["name"]: m for m in entry.get("models", [])} merged.update(migrated_models) entry["models"] = list(merged.values()) entry["source"] = MIGRATION_SOURCE if p["display_name"]: entry.setdefault("display_name", p["display_name"]) if p.get("source_base_url"): entry["source_base_url"] = p["source_base_url"] if p.get("endpoint_unknown"): entry["endpoint_unknown"] = True if p.get("api_key_ref"): entry["api_key_ref"] = p["api_key_ref"] if p.get("auth_kind"): entry.setdefault("auth_kind", p["auth_kind"]) if p.get("oauth_ref"): entry["oauth_ref"] = p["oauth_ref"] if p.get("alt_key_refs"): entry["alt_key_refs"] = p["alt_key_refs"] providers[pid] = entry d = os.path.dirname(config_path) or "." os.makedirs(d, exist_ok=True) tmp = config_path + ".tmp" with open(tmp, "w") as f: json.dump(cfg, f, indent=2, sort_keys=True) os.chmod(tmp, 0o600) os.replace(tmp, config_path) return { "applied_providers": applied, "stored_secrets": stored, "skipped": plan["skipped"], "model_selection": plan["model_selection"], "store_kind": store.kind(), "config_path": config_path, "migrated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } def verify_migration(plan, receipt, config_path, store): """Every key found in mimo is present in rune, counts match, no plaintext in the config or the receipt.""" materials = [s["material"] for s in plan["secrets"]] for s in plan["secrets"]: for alt in s.get("alt_refs", []): materials.append(s["material"]) missing = [] for row in receipt["stored_secrets"]: got = store.get(row["ref"]) if got is None or ss.fingerprint(got) != row["fingerprint"]: missing.append(row["ref"]) for s in plan["secrets"]: for alt in s.get("alt_refs", []): got = store.get(alt) if got is None or ss.fingerprint(got) != ss.fingerprint(s["material"]): missing.append(alt) leaks = [] with open(str(config_path)) as f: config_text = f.read() for material in set(materials): if material and material in config_text: leaks.append(f"providers.json contains plaintext {ss.fingerprint(material)}") for material in set(materials): if material and material in json.dumps(receipt): leaks.append(f"receipt contains plaintext {ss.fingerprint(material)}") provider_ids = {p["id"] for p in plan["providers"]} config_path = str(config_path) present = set(_load_rune_config(str(config_path)).get("providers", {})) providers_ok = provider_ids <= present found = len(plan["secrets"]) + sum(len(s.get("alt_refs", [])) for s in plan["secrets"]) stored = len(receipt["stored_secrets"]) + sum( len(r.get("alt_refs", [])) for r in receipt["stored_secrets"]) return { "ok": not missing and not leaks and providers_ok and found == stored, "found_secrets": found, "stored_secrets": stored, "missing": missing, "plaintext_leaks": leaks, "providers_ok": providers_ok, "providers_total": len(provider_ids), } # ═════════════════════════════════════════════════════════════════════ # report # ═════════════════════════════════════════════════════════════════════ def write_report(path, receipt, verdict): lines = [ "# API key migration — mimo → rune (B-APIKEY-MIGRATION)", "", f"- migrated_at: {receipt['migrated_at']}", f"- store: {receipt['store_kind']} (OS keyring / Secret Service / encrypted file)", f"- rune provider config: {receipt['config_path']}", f"- providers applied: {receipt['applied_providers']}", f"- distinct secrets migrated: {len(receipt['stored_secrets'])}", f"- skipped (not credentials): {len(receipt['skipped'])}", "", "## Secrets (fingerprints only — no plaintext ever)", "", "| ref | kind | provider | aliases | active | fingerprint |", "|---|---|---|---|---|---|", ] for row in receipt["stored_secrets"]: lines.append( f"| {row['ref']} | {row['kind']} | {row['provider']} | " f"{', '.join(row['aliases'])} | {'yes' if row['active'] else 'no'} " f"| {row['fingerprint']} |" ) lines += ["", "## Skipped", ""] for s in receipt["skipped"]: lines.append(f"- {s['provider']} ({s['origin']}): {s['reason']}") unknown = [r for r in receipt["stored_secrets"]] lines += ["", "## Verification", ""] lines.append(f"- verdict: {'GREEN' if verdict['ok'] else 'RED'}") lines.append(f"- found distinct secrets in mimo: {verdict['found_secrets']}") lines.append(f"- present in rune store: {verdict['stored_secrets']}") lines.append(f"- missing: {verdict['missing'] or 'none'}") lines.append(f"- plaintext leaks: {verdict['plaintext_leaks'] or 'none'}") lines.append(f"- provider entries present: {verdict['providers_ok']}") sel = receipt.get("model_selection") or {} if sel.get("model"): lines.append(f"- saved model selection carried as metadata: {sel['model']}") lines.append("") lines.append("approval_ref: user:wave-apikey-migration dispatch") lines.append("") text = "\n".join(lines) # defense in depth: the report must never carry material for s in getattr(write_report, "_materials", []): if s and s in text: raise RuntimeError("report would contain plaintext — refusing to write") os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "w") as f: f.write(text) return text # ═════════════════════════════════════════════════════════════════════ # CLI # ═════════════════════════════════════════════════════════════════════ def main(argv=None): ap = argparse.ArgumentParser(description="migrate mimo API keys into rune") ap.add_argument("--apply", action="store_true", help="write the migration (default: dry-run)") ap.add_argument("--home", default=os.path.expanduser("~"), help="home dir holding the mimo stores") ap.add_argument("--rune-config", default=os.path.expanduser("~/.config/rune/providers.json")) ap.add_argument("--store", default="auto", choices=["auto", "keyring", "secret-tool", "file", "memory"]) ap.add_argument("--report", default=None, help="write the verification report to this path") args = ap.parse_args(argv) plan = collect(Sources(home=args.home)) print(f"providers found: {len(plan['providers'])}") print(f"distinct secrets found: {len(plan['secrets'])}") print(f"skipped: {len(plan['skipped'])}") if not args.apply: print("dry-run — pass --apply to migrate") return 0 store = ss.resolve_secret_store(explicit=args.store) receipt = apply_migration(plan, config_path=args.rune_config, store=store) verdict = verify_migration(plan, receipt, config_path=args.rune_config, store=store) if args.report: write_report._materials = [s["material"] for s in plan["secrets"]] write_report(args.report, receipt, verdict) print(f"report: {args.report}") print(f"store: {receipt['store_kind']}") print(f"secrets migrated: {len(receipt['stored_secrets'])} / {verdict['found_secrets']}") print(f"verification: {'GREEN' if verdict['ok'] else 'RED'}") if not verdict["ok"]: print(json.dumps({k: verdict[k] for k in ("missing", "plaintext_leaks", "providers_ok")})) return 1 return 0 if __name__ == "__main__": sys.exit(main())