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.
613 lines
25 KiB
Python
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())
|