Files
rune/scripts/migrate_api_keys.py
T
Nick 1e85064944 feat(tui+runtime): chat-live P1 — the face talks through the real core
PTY PROOF: clean RUNE_HOME -> gate card (no provider configured) ->
model list -> digit selects (record persisted) -> typed message -> real
Ollama reply lands complete in the lane -> checkpoint holds user+
assistant msg_ids. The face talks.

F1-F4 + mutex: channel honoring (chat lands in chat, not task), stale
canary stub killed + supervised real core on 8376 (ensure_core.py,
identity sanity), timeout budget >=330s all three legs, bridge polls
channel chat beside task. Thread-safety: dispatch_lock mutex (canary
overlap probe was RED pre-fix, 35/0 post, 4-way concurrency pytest,
rogue-mutant teeth).

8-role bubble structure per the design-intent contracts: user right/
assistant left, thinking collapsed, dark bg. Fresh-state: gate card
with real model list, no default, record persisted, chat gated until
selection. Full module green (32 pkgs + 4 with new content), race-
clean. Identity line: runtime lib:connected.

KNOWN: chatpane contrast fails WCAG on the dark bg (1.35:1 assistant
invisible) — visual round build wave lifts every role to >=4.5:1 per
direction; identity line clips chat token at 100 cols; ordinary
sentences can't be typed on the chat pane while chain keys r/t/p/y/
route to chrome — the selection law's pending-input surfaces need
unbinding in the visual round. These are DESIGN findings, not defects.
2026-10-18 01:41:10 +01:00

613 lines
25 KiB
Python

#!/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())