File size: 8,511 Bytes
ee68a9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21859b2
ee68a9d
21859b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee68a9d
21859b2
 
 
e499dd1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
020b203
e499dd1
805b3d2
 
020b203
 
805b3d2
e499dd1
020b203
 
 
805b3d2
e499dd1
21859b2
805b3d2
 
21859b2
 
 
e499dd1
 
21859b2
 
 
 
020b203
 
21859b2
 
 
 
 
 
020b203
21859b2
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""
OpenCode Space — Automatic Credentials Synchronizer
===================================================
Automatically captures API keys and credentials entered in the OmniRoute Web UI
and persists them into:
  1. Persistent Disk Vault (/data/omniroute/credentials_vault.json)
  2. Hugging Face Space Secrets API (if HF_TOKEN with write permission is present)
  3. Active Process Environment (os.environ)
"""

import os
import json
import logging
import urllib.request
import urllib.error

logger = logging.getLogger("CredentialsSync")

VAULT_PATH = "/data/omniroute/credentials_vault.json"
SPACE_ID = os.environ.get("SPACE_ID", "Jishnupg/Opencode-Cli")

PROVIDER_ENV_MAP = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "claude": "ANTHROPIC_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "google": "GEMINI_API_KEY",
    "antigravity": "ANTIGRAVITY_API_KEY",
    "deepseek": "DEEPSEEK_API_KEY",
    "groq": "GROQ_API_KEY",
    "nvidia": "NVIDIA_API_KEY",
    "nvidia-nim": "NVIDIA_API_KEY",
    "huggingface": "HF_TOKEN",
    "hf": "HF_TOKEN",
    "openrouter": "OPENROUTER_API_KEY",
    "mistral": "MISTRAL_API_KEY",
    "cohere": "COHERE_API_KEY",
    "moonshot": "MOONSHOT_API_KEY",
    "kimi": "MOONSHOT_API_KEY",
    "kimi-coding": "MOONSHOT_API_KEY",
    "together": "TOGETHER_API_KEY",
    "fireworks": "FIREWORKS_API_KEY",
    "cerebras": "CEREBRAS_API_KEY",
    "perplexity": "PERPLEXITY_API_KEY",
    "xai": "XAI_API_KEY",
    "grok": "XAI_API_KEY",
    "github": "GITHUB_TOKEN",
}

def normalize_provider_name(name: str) -> str:
    if not name:
        return ""
    clean = name.lower().strip()
    for k, v in PROVIDER_ENV_MAP.items():
        if k in clean:
            return v
    # Fallback: create a sanitized env var name
    sanitized = "".join(c if c.isalnum() else "_" for c in name.upper())
    return f"{sanitized}_API_KEY"

def save_to_local_vault(env_name: str, secret_val: str):
    """Saves key-value pairs into /data/omniroute/credentials_vault.json."""
    if not env_name or not secret_val:
        return
    try:
        os.makedirs(os.path.dirname(VAULT_PATH), exist_ok=True)
        vault = {}
        if os.path.exists(VAULT_PATH):
            try:
                with open(VAULT_PATH, "r", encoding="utf-8") as f:
                    vault = json.load(f)
            except Exception:
                vault = {}
        
        vault[env_name] = secret_val
        with open(VAULT_PATH, "w", encoding="utf-8") as f:
            json.dump(vault, f, indent=2)
        
        # Inject into active process environment
        os.environ[env_name] = secret_val
        logger.info(f"[VAULT] Saved {env_name} to {VAULT_PATH} & active os.environ")
    except Exception as e:
        logger.warning(f"[VAULT] Failed to save {env_name} to local vault: {e}")

def sync_secret_to_huggingface(secret_name: str, secret_val: str):
    """Syncs a secret to Hugging Face Space Secrets via REST API."""
    token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_API_KEY")
    if not token or not secret_name or not secret_val:
        return False

    url = f"https://huggingface.co/api/spaces/{SPACE_ID}/secrets"
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "User-Agent": "OpenCode-Space-Vault/1.0"
    }
    payload = {
        "key": secret_name,
        "value": secret_val,
        "description": "Auto-synced from OmniRoute Web UI"
    }
    try:
        req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
        with urllib.request.urlopen(req, timeout=10) as resp:
            if resp.status in (200, 201):
                logger.info(f"[HF_SYNC] Successfully uploaded secret {secret_name} to Hugging Face Space Secrets!")
                return True
    except urllib.error.HTTPError as e:
        # If already exists, try PUT or ignore
        if e.code in (400, 409):
            try:
                req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="PUT")
                with urllib.request.urlopen(req, timeout=10) as resp:
                    logger.info(f"[HF_SYNC] Updated secret {secret_name} in Hugging Face Space Secrets.")
                    return True
            except Exception:
                pass
        logger.warning(f"[HF_SYNC] HF API returned status {e.code} while syncing {secret_name}: {e.read().decode('utf-8', 'ignore')}")
    except Exception as e:
        logger.warning(f"[HF_SYNC] Failed to sync {secret_name} to HF Space: {e}")
    return False

def handle_captured_credential(provider_hint: str, api_key: str):
    """High-level dispatch to save locally and attempt HF cloud sync."""
    if not api_key:
        return
    api_key_str = str(api_key).strip()
    if len(api_key_str) < 5:
        return
    
    # Check if this is a structured JSON string (like Antigravity OAuth object)
    if api_key_str.startswith("{") and api_key_str.endswith("}"):
        try:
            parsed = json.loads(api_key_str)
            if isinstance(parsed, dict):
                # Extract refresh token or access token if available
                rt = parsed.get("refresh_token") or parsed.get("token") or parsed.get("access_token")
                if rt and isinstance(rt, str):
                    env_name = normalize_provider_name(provider_hint)
                    save_to_local_vault(env_name, rt)
                    sync_secret_to_huggingface(env_name, rt)
        except Exception:
            pass

    env_name = normalize_provider_name(provider_hint)
    save_to_local_vault(env_name, api_key_str)
    sync_secret_to_huggingface(env_name, api_key_str)

import threading

_sync_timer = None
_sync_lock = threading.Lock()

def _debounced_vault_backup():
    try:
        from vault_sync import backup_to_vault
        backup_to_vault()
    except Exception as e:
        logger.warning(f"[PERSISTENCE] Debounced vault backup failed: {e}")

def schedule_vault_sync(delay_seconds: float = 15.0):
    global _sync_timer
    with _sync_lock:
        if _sync_timer and _sync_timer.is_alive():
            _sync_timer.cancel()
        _sync_timer = threading.Timer(delay_seconds, _debounced_vault_backup)
        _sync_timer.daemon = True
        _sync_timer.start()

def flush_omniroute_db():
    """Safely inspects database in read-only mode and schedules debounced cloud backup."""
    db_path = os.path.join(os.environ.get("DATA_DIR", "/data/omniroute"), "storage.sqlite")
    if not os.path.exists(db_path) or os.path.getsize(db_path) == 0:
        return
    try:
        sync_sqlite_credentials_to_vault(db_path)
        schedule_vault_sync(15.0)
    except Exception as e:
        logger.warning(f"[PERSISTENCE] flush_omniroute_db error: {e}")

def sync_sqlite_credentials_to_vault(db_path=None):
    """Scans OmniRoute SQLite database in read-only mode for any newly configured providers and syncs them."""
    import sqlite3
    if not db_path:
        db_path = os.path.join(os.environ.get("DATA_DIR", "/data/omniroute"), "storage.sqlite")
    if not os.path.exists(db_path):
        return
    try:
        # Open in read-only URI mode to prevent any WAL locking conflicts with Node.js
        conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
        cursor = conn.cursor()
        cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
        tables = [t[0] for t in cursor.fetchall()]
        
        # Check connections and provider tables
        for tbl in ["connections", "provider_connections", "keys", "providers"]:
            if tbl in tables:
                cursor.execute(f"SELECT * FROM {tbl}")
                rows = cursor.fetchall()
                col_names = [d[0].lower() for d in cursor.description]
                for r in rows:
                    row_dict = dict(zip(col_names, r))
                    provider = str(row_dict.get("provider_id") or row_dict.get("provider") or row_dict.get("name") or row_dict.get("id") or "")
                    key_val = str(row_dict.get("api_key") or row_dict.get("key") or row_dict.get("token") or row_dict.get("credentials") or "")
                    if provider and key_val and len(key_val) > 5 and not key_val.startswith("enc:"):
                        handle_captured_credential(provider, key_val)
        conn.close()
    except Exception as e:
        logger.debug(f"[VAULT] SQLite scan skipped: {e}")