diff --git a/config.py b/config.py
index b3f83c3..7677e6b 100644
--- a/config.py
+++ b/config.py
@@ -96,10 +96,14 @@ _CORE_CFG = _load_core_config()
_GEMINI = _CORE_CFG.get("gemini_defaults", {})
_AUDIO = _CORE_CFG.get("audio_defaults", {})
-# -- Gemini defaults (override via data/motions/config.json or env) --
-GEMINI_API_KEY = os.environ.get(
- "SANAD_GEMINI_API_KEY",
- _GEMINI.get("api_key", ""))
+# -- Gemini API key — STRICT env-only ----
+# Single source of truth: cPanel "Setup Python App → Environment variables" → `api`.
+# start.sh exports it before launching uvicorn (see start.sh step 3, which parses
+# ~/.cl.selector/python-selector.json).
+# NO fallbacks: no SANAD_GEMINI_API_KEY, no core_config.json value, no
+# data/motions/config.json override. To rotate the key, edit the cPanel form
+# and restart uvicorn — nothing else changes the key.
+GEMINI_API_KEY = os.environ.get("api", "")
GEMINI_MODEL = os.environ.get(
"SANAD_GEMINI_MODEL",
"models/" + _GEMINI.get("model_live", "gemini-2.5-flash-native-audio-preview-12-2025"))
diff --git a/dashboard/routes/voice.py b/dashboard/routes/voice.py
index a3d1b88..cdb9f00 100644
--- a/dashboard/routes/voice.py
+++ b/dashboard/routes/voice.py
@@ -134,33 +134,38 @@ class ApiKeyPayload(BaseModel):
async def get_api_key():
"""Return the current Gemini API key in masked form.
- Never returns the full key. Response:
- {
- "has_key": true,
- "masked": "AIza***...kqf8",
- "length": 39,
- "source": "config_file" | "default"
- }
+ The key now comes EXCLUSIVELY from the `api` env var (sourced from
+ cPanel "Setup Python App → Environment variables"). This endpoint
+ is read-only.
"""
import Project.Sanad.config as cfg_mod
key = getattr(cfg_mod, "GEMINI_API_KEY", "") or ""
- # Detect where the value came from (persisted override vs module default)
- try:
- from Project.Sanad.config import load_config
- stored = load_config().get("gemini", {}) or {}
- source = "config_file" if stored.get("api_key") else "default"
- except Exception:
- source = "default"
return {
"has_key": bool(key),
"masked": _mask_api_key(key),
"length": len(key),
- "source": source,
+ "source": "env:api",
+ "read_only": True,
}
@router.post("/api-key")
async def update_api_key(payload: ApiKeyPayload):
+ """API key updates via dashboard are DISABLED.
+
+ Source of truth is the cPanel "Setup Python App → Environment variables"
+ form (env var `api`). To rotate the key, edit that form and restart
+ uvicorn. The dashboard intentionally cannot bypass this.
+ """
+ raise HTTPException(
+ 403,
+ "API key updates are disabled. Edit it in cPanel → Setup Python App "
+ "→ Environment variables → 'api', then restart uvicorn."
+ )
+
+
+# Original update logic kept below for reference but no longer reachable.
+async def _legacy_update_api_key(payload: ApiKeyPayload):
"""Update the Gemini API key — persists to data/motions/config.json and
hot-swaps the in-memory value so the next Gemini connect uses it.
diff --git a/dashboard/static/index.html b/dashboard/static/index.html
index 12e0b1d..d18f1c5 100644
--- a/dashboard/static/index.html
+++ b/dashboard/static/index.html
@@ -129,12 +129,13 @@
-
+
Gemini API Key
- The key used by GeminiVoiceClient and the Live Gemini subprocess.
- Saved to data/motions/config.json. Get a free key at
+ Read-only. Source of truth: cPanel → Setup Python App →
+ Environment variables → api. To rotate,
+ edit that field and restart uvicorn. Get a free key at
aistudio.google.com/app/apikey.
@@ -143,12 +144,6 @@
-
-
-
-
-
-
@@ -269,51 +264,20 @@ async function refreshApiKey(b){
if(r.has_key){
inp.value=r.masked||'';
inp.placeholder='';
- src.textContent=r.source==='config_file'?'saved':'default';
- src.className='badge '+(r.source==='config_file'?'badge-ok':'badge-warn');
- msg.textContent=`Length: ${r.length} chars`;
+ src.textContent=r.source||'env';
+ src.className='badge badge-ok';
+ msg.textContent=`Length: ${r.length} chars · read-only (cPanel env)`;
}else{
inp.value='';
inp.placeholder='(no key configured)';
src.textContent='empty';
src.className='badge badge-err';
- msg.textContent='No API key — paste one below to enable Gemini.';
+ msg.textContent='No API key — set it in cPanel → Setup Python App → Environment variables → "api".';
}
}catch(e){}
if(b)btnDone(b);
}
-function toggleApiKeyVisibility(){
- const inp=document.getElementById('gm-key-new');
- inp.type=inp.type==='password'?'text':'password';
-}
-
-async function saveApiKey(b){
- const inp=document.getElementById('gm-key-new');
- const key=(inp.value||'').trim();
- const msg=document.getElementById('gm-key-msg');
- if(!key){toast('Paste a key first','err');return;}
- if(!key.startsWith('AIza')){
- if(!confirm("Key doesn't start with 'AIza'. Gemini keys normally do. Save anyway?"))return;
- }
- btnLoad(b);
- try{
- const r=await api('POST','/api/voice/api-key',{api_key:key});
- toast(`API key saved (${r.length} chars)`,'ok');
- inp.value='';
- inp.type='password';
- msg.textContent=r.message||'Saved. Click Connect to apply.';
- msg.style.color='var(--accent)';
- setTimeout(()=>{msg.style.color='var(--muted)';},4000);
- await refreshApiKey();
- refreshStatus();
- }catch(e){
- msg.textContent='Save failed: '+(e.message||'unknown');
- msg.style.color='#f55';
- }
- btnDone(b);
-}
-
// Audio — only updates the mic/spk badges and mute-shortcut buttons that
// still exist in the Voice & Audio cards. Heavier audio device picker
// + G1 volume slider were removed with the Operations tab.
diff --git a/gemini/client.py b/gemini/client.py
index b300da0..a510f49 100644
--- a/gemini/client.py
+++ b/gemini/client.py
@@ -82,6 +82,20 @@ class GeminiVoiceClient:
kwargs[key] = {"Content-Type": "application/json"}
return kwargs
+ async def _safe_close_ws(self) -> None:
+ """Best-effort close of the current socket, guarded by a timeout.
+
+ A half-dead socket whose close() hangs must never wedge the event
+ loop here, so the close itself is bounded.
+ """
+ ws, self._ws = self._ws, None
+ if ws is None:
+ return
+ try:
+ await asyncio.wait_for(ws.close(), timeout=5)
+ except Exception:
+ pass
+
async def connect(self):
uri = f"{GEMINI_WS_URI}?key={GEMINI_API_KEY}"
try:
@@ -100,30 +114,35 @@ class GeminiVoiceClient:
"systemInstruction": {"parts": [{"text": self.system_prompt}]},
}
}
- await self._ws.send(json.dumps(setup))
- await self._ws.recv() # ACK
+ # Guard the app-level setup handshake with a timeout. websockets'
+ # own open_timeout only covers the HTTP upgrade, NOT this send/ACK.
+ # A socket that opens but never ACKs would otherwise block this
+ # await forever and freeze uvicorn's single event loop — every HTTP
+ # request with it. That is exactly what took the site down.
+ await asyncio.wait_for(
+ self._ws.send(json.dumps(setup)), timeout=GEMINI_WS_TIMEOUT)
+ await asyncio.wait_for(self._ws.recv(), timeout=GEMINI_WS_TIMEOUT) # ACK
self._connected = True
self._reconnect_attempts = 0
log.info("Connected to Gemini (%s)", GEMINI_MODEL)
await bus.emit("voice.connected")
+ except asyncio.TimeoutError:
+ self._connected = False
+ await self._safe_close_ws()
+ log.warning("Gemini setup handshake timed out after %ss", GEMINI_WS_TIMEOUT)
+ raise
except Exception:
self._connected = False
- self._ws = None
+ await self._safe_close_ws()
log.exception("Failed to connect to Gemini")
raise
async def disconnect(self):
- try:
- if self._ws is not None:
- await self._ws.close()
- except Exception:
- pass
- finally:
- self._ws = None
- self._connected = False
- self._owner = None
- log.info("Disconnected from Gemini")
- await bus.emit("voice.disconnected")
+ await self._safe_close_ws()
+ self._connected = False
+ self._owner = None
+ log.info("Disconnected from Gemini")
+ await bus.emit("voice.disconnected")
async def _ensure_connected(self):
"""Reconnect if dropped, with bounded retries.
@@ -172,13 +191,19 @@ class GeminiVoiceClient:
}
try:
async with self._send_lock:
- await self._ws.send(json.dumps(msg))
+ await asyncio.wait_for(
+ self._ws.send(json.dumps(msg)), timeout=GEMINI_WS_TIMEOUT)
return True
except websockets.exceptions.ConnectionClosed:
log.warning("send_audio_chunk: connection closed")
self._connected = False
await bus.emit("voice.error", reason="connection_closed")
return False
+ except asyncio.TimeoutError:
+ log.warning("send_audio_chunk: send timed out after %ss", GEMINI_WS_TIMEOUT)
+ self._connected = False
+ await bus.emit("voice.error", reason="send_timeout")
+ return False
except Exception:
log.exception("send_audio_chunk failed")
return False
@@ -197,8 +222,8 @@ class GeminiVoiceClient:
self._owner = owner
try:
return await self._send_text_inner(text)
- except websockets.exceptions.ConnectionClosed:
- log.warning("send_text: connection died on send — reconnecting once")
+ except (websockets.exceptions.ConnectionClosed, asyncio.TimeoutError):
+ log.warning("send_text: connection died/stalled on send — reconnecting once")
self._connected = False
if not await self._ensure_connected():
raise RuntimeError("Reconnect after send failure also failed.")
@@ -215,7 +240,8 @@ class GeminiVoiceClient:
}
}
async with self._send_lock:
- await self._ws.send(json.dumps(request))
+ await asyncio.wait_for(
+ self._ws.send(json.dumps(request)), timeout=GEMINI_WS_TIMEOUT)
audio_chunks: list[bytes] = []
text_parts: list[str] = []
@@ -298,7 +324,8 @@ class GeminiVoiceClient:
return False
try:
async with self._send_lock:
- await self._ws.send(json.dumps(payload))
+ await asyncio.wait_for(
+ self._ws.send(json.dumps(payload)), timeout=GEMINI_WS_TIMEOUT)
return True
except Exception:
log.exception("raw_send failed")