bosun 0.36.6 → 0.36.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.36.6",
3
+ "version": "0.36.7",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -1604,6 +1604,21 @@ function handleApply(body) {
1604
1604
  if (env.copilotEnableAskUser) envMap.COPILOT_ENABLE_ASK_USER = "true";
1605
1605
  if (env.copilotMcpConfig) envMap.COPILOT_MCP_CONFIG = env.copilotMcpConfig;
1606
1606
 
1607
+ // ── Sentinel / watchdog ────────────────────────────────────────────────
1608
+ if (env.sentinelAutoStart != null) envMap.BOSUN_SENTINEL_AUTO_START = env.sentinelAutoStart ? "true" : "false";
1609
+ if (env.sentinelStrict != null) envMap.BOSUN_SENTINEL_STRICT = env.sentinelStrict ? "true" : "false";
1610
+ if (env.sentinelAutoRestartMonitor != null) envMap.SENTINEL_AUTO_RESTART_MONITOR = env.sentinelAutoRestartMonitor ? "true" : "false";
1611
+ if (env.sentinelCrashLoopThreshold) envMap.SENTINEL_CRASH_LOOP_THRESHOLD = String(env.sentinelCrashLoopThreshold);
1612
+ if (env.sentinelCrashLoopWindowMin) envMap.SENTINEL_CRASH_LOOP_WINDOW_MIN = String(env.sentinelCrashLoopWindowMin);
1613
+ if (env.sentinelRepairAgentEnabled != null) envMap.SENTINEL_REPAIR_AGENT_ENABLED = env.sentinelRepairAgentEnabled ? "true" : "false";
1614
+ if (env.sentinelRepairTimeoutMin) envMap.SENTINEL_REPAIR_TIMEOUT_MIN = String(env.sentinelRepairTimeoutMin);
1615
+
1616
+ // ── Daemon restart policy ──────────────────────────────────────────────
1617
+ if (env.daemonRestartDelayMs != null) envMap.RESTART_DELAY_MS = String(env.daemonRestartDelayMs);
1618
+ if (env.daemonMaxRestarts != null) envMap.MAX_RESTARTS = String(env.daemonMaxRestarts);
1619
+ if (env.daemonMaxInstantRestarts) envMap.BOSUN_DAEMON_MAX_INSTANT_RESTARTS = String(env.daemonMaxInstantRestarts);
1620
+ if (env.daemonInstantCrashWindowMs) envMap.BOSUN_DAEMON_INSTANT_CRASH_WINDOW_MS = String(env.daemonInstantCrashWindowMs);
1621
+
1607
1622
  // Ensure every setup field has safe defaults and invalid values are normalized.
1608
1623
  applyNonBlockingSetupEnvDefaults(envMap, env, process.env);
1609
1624
 
package/ui/setup.html CHANGED
@@ -723,6 +723,19 @@ function App() {
723
723
  const [whatsappEnabled, setWhatsappEnabled] = useState(false);
724
724
  const [telegramIntervalMin, setTelegramIntervalMin] = useState(10);
725
725
  const [orchestratorScript, setOrchestratorScript] = useState("");
726
+ // Sentinel / watchdog
727
+ const [sentinelAutoStart, setSentinelAutoStart] = useState(false);
728
+ const [sentinelStrict, setSentinelStrict] = useState(false);
729
+ const [sentinelAutoRestartMonitor, setSentinelAutoRestartMonitor] = useState(true);
730
+ const [sentinelCrashLoopThreshold, setSentinelCrashLoopThreshold] = useState(3);
731
+ const [sentinelCrashLoopWindowMin, setSentinelCrashLoopWindowMin] = useState(5);
732
+ const [sentinelRepairAgentEnabled, setSentinelRepairAgentEnabled] = useState(false);
733
+ const [sentinelRepairTimeoutMin, setSentinelRepairTimeoutMin] = useState(15);
734
+ // Daemon restart policy
735
+ const [daemonRestartDelayMs, setDaemonRestartDelayMs] = useState(10000);
736
+ const [daemonMaxRestarts, setDaemonMaxRestarts] = useState(0);
737
+ const [daemonMaxInstantRestarts, setDaemonMaxInstantRestarts] = useState(3);
738
+ const [daemonInstantCrashWindowMs, setDaemonInstantCrashWindowMs] = useState(15000);
726
739
  // Launch / startup
727
740
  const [autoStartEnabled, setAutoStartEnabled] = useState(false);
728
741
  const [desktopShortcutEnabled, setDesktopShortcutEnabled] = useState(false);
@@ -1231,6 +1244,19 @@ function App() {
1231
1244
  if (env.WHATSAPP_ENABLED) { setWhatsappEnabled(env.WHATSAPP_ENABLED === "true"); envLoaded = true; }
1232
1245
  if (env.TELEGRAM_INTERVAL_MIN) { setTelegramIntervalMin(Number(env.TELEGRAM_INTERVAL_MIN) || 10); envLoaded = true; }
1233
1246
  if (env.ORCHESTRATOR_SCRIPT) { setOrchestratorScript(env.ORCHESTRATOR_SCRIPT); envLoaded = true; }
1247
+ // Sentinel / watchdog
1248
+ if (env.BOSUN_SENTINEL_AUTO_START) { setSentinelAutoStart(env.BOSUN_SENTINEL_AUTO_START === "true"); envLoaded = true; }
1249
+ if (env.BOSUN_SENTINEL_STRICT) { setSentinelStrict(env.BOSUN_SENTINEL_STRICT === "true"); envLoaded = true; }
1250
+ if (env.SENTINEL_AUTO_RESTART_MONITOR !== undefined) { setSentinelAutoRestartMonitor(env.SENTINEL_AUTO_RESTART_MONITOR !== "false"); envLoaded = true; }
1251
+ if (env.SENTINEL_CRASH_LOOP_THRESHOLD) { setSentinelCrashLoopThreshold(Number(env.SENTINEL_CRASH_LOOP_THRESHOLD) || 3); envLoaded = true; }
1252
+ if (env.SENTINEL_CRASH_LOOP_WINDOW_MIN) { setSentinelCrashLoopWindowMin(Number(env.SENTINEL_CRASH_LOOP_WINDOW_MIN) || 5); envLoaded = true; }
1253
+ if (env.SENTINEL_REPAIR_AGENT_ENABLED) { setSentinelRepairAgentEnabled(env.SENTINEL_REPAIR_AGENT_ENABLED === "true"); envLoaded = true; }
1254
+ if (env.SENTINEL_REPAIR_TIMEOUT_MIN) { setSentinelRepairTimeoutMin(Number(env.SENTINEL_REPAIR_TIMEOUT_MIN) || 15); envLoaded = true; }
1255
+ // Daemon restart policy
1256
+ if (env.RESTART_DELAY_MS) { setDaemonRestartDelayMs(Number(env.RESTART_DELAY_MS) || 10000); envLoaded = true; }
1257
+ if (env.MAX_RESTARTS) { setDaemonMaxRestarts(Number(env.MAX_RESTARTS) || 0); envLoaded = true; }
1258
+ if (env.BOSUN_DAEMON_MAX_INSTANT_RESTARTS) { setDaemonMaxInstantRestarts(Number(env.BOSUN_DAEMON_MAX_INSTANT_RESTARTS) || 3); envLoaded = true; }
1259
+ if (env.BOSUN_DAEMON_INSTANT_CRASH_WINDOW_MS) { setDaemonInstantCrashWindowMs(Number(env.BOSUN_DAEMON_INSTANT_CRASH_WINDOW_MS) || 15000); envLoaded = true; }
1234
1260
  // Voice settings
1235
1261
  if (env.VOICE_ENABLED !== undefined) { setVoiceEnabled(env.VOICE_ENABLED !== "false"); envLoaded = true; }
1236
1262
  if (env.VOICE_PROVIDER) { setVoiceProvider(env.VOICE_PROVIDER); envLoaded = true; }
@@ -1587,6 +1613,19 @@ function App() {
1587
1613
  whatsappEnabled,
1588
1614
  telegramIntervalMin,
1589
1615
  orchestratorScript,
1616
+ // Sentinel / watchdog
1617
+ sentinelAutoStart,
1618
+ sentinelStrict,
1619
+ sentinelAutoRestartMonitor,
1620
+ sentinelCrashLoopThreshold,
1621
+ sentinelCrashLoopWindowMin,
1622
+ sentinelRepairAgentEnabled,
1623
+ sentinelRepairTimeoutMin,
1624
+ // Daemon restart policy
1625
+ daemonRestartDelayMs,
1626
+ daemonMaxRestarts,
1627
+ daemonMaxInstantRestarts,
1628
+ daemonInstantCrashWindowMs,
1590
1629
  },
1591
1630
  configJson: {
1592
1631
  projectName,
@@ -3145,6 +3184,8 @@ function App() {
3145
3184
  <select value=${ep.provider} onchange=${(e) => updateVoiceEndpoint(i, "provider", e.target.value)}>
3146
3185
  <option value="azure">Azure OpenAI</option>
3147
3186
  <option value="openai">OpenAI</option>
3187
+ <option value="claude">Claude (Anthropic)</option>
3188
+ <option value="gemini">Gemini (Google)</option>
3148
3189
  </select>
3149
3190
  </div>
3150
3191
  <div class="form-group">
@@ -3185,6 +3226,23 @@ function App() {
3185
3226
  <input type="text" value=${ep.model} oninput=${(e) => updateVoiceEndpoint(i, "model", e.target.value)} placeholder="gpt-audio-1.5" />
3186
3227
  </div>
3187
3228
  `}
3229
+ ${ep.provider === "claude" && html`
3230
+ <div class="form-group">
3231
+ <label>Model</label>
3232
+ <input type="text" value=${ep.model} oninput=${(e) => updateVoiceEndpoint(i, "model", e.target.value)} placeholder="claude-opus-4-5" />
3233
+ </div>
3234
+ `}
3235
+ ${ep.provider === "gemini" && html`
3236
+ <div class="form-group">
3237
+ <label>Model</label>
3238
+ <input type="text" value=${ep.model} oninput=${(e) => updateVoiceEndpoint(i, "model", e.target.value)} placeholder="gemini-2.0-flash" />
3239
+ </div>
3240
+ `}
3241
+ <div class="form-group">
3242
+ <label>Vision Model</label>
3243
+ <input type="text" value=${ep.visionModel || ""} oninput=${(e) => updateVoiceEndpoint(i, "visionModel", e.target.value)} placeholder="e.g. gpt-4.1-nano" />
3244
+ <div class="hint">Optional model for vision/image tasks on this endpoint.</div>
3245
+ </div>
3188
3246
  </div>
3189
3247
  </div>
3190
3248
  `)}
@@ -3250,7 +3308,102 @@ function App() {
3250
3308
  </div>
3251
3309
  <//>
3252
3310
 
3253
- <${Section} title="🚀 Launch & Startup">
3311
+ <${Section} title="�️ Sentinel / Watchdog">
3312
+ <p class="hint" style="margin-top:0;margin-bottom:16px">
3313
+ The Sentinel continuously monitors bosun and auto-restarts it if it crashes or enters a crash loop.
3314
+ </p>
3315
+ <div class="form-group" style="margin-bottom:12px">
3316
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3317
+ <input type="checkbox" checked=${sentinelAutoStart}
3318
+ onchange=${(e) => setSentinelAutoStart(e.target.checked)}
3319
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
3320
+ Enable Sentinel / Watchdog
3321
+ </label>
3322
+ <div class="hint">Bosun will be watched by an external monitor process that restarts it on failure.</div>
3323
+ </div>
3324
+ <div class="form-group" style="margin-bottom:12px">
3325
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3326
+ <input type="checkbox" checked=${sentinelStrict}
3327
+ onchange=${(e) => setSentinelStrict(e.target.checked)}
3328
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
3329
+ Strict Mode
3330
+ </label>
3331
+ <div class="hint">In strict mode the sentinel will refuse to start bosun if it detects unsafe conditions.</div>
3332
+ </div>
3333
+ <div class="form-group" style="margin-bottom:12px">
3334
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3335
+ <input type="checkbox" checked=${sentinelAutoRestartMonitor}
3336
+ onchange=${(e) => setSentinelAutoRestartMonitor(e.target.checked)}
3337
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
3338
+ Auto-restart monitor on crash
3339
+ </label>
3340
+ <div class="hint">The monitor process itself will be restarted if it exits unexpectedly.</div>
3341
+ </div>
3342
+ <div class="executor-grid">
3343
+ <div class="form-group">
3344
+ <label>Crash Loop Threshold</label>
3345
+ <input type="number" value=${sentinelCrashLoopThreshold} min="1" max="20"
3346
+ oninput=${(e) => setSentinelCrashLoopThreshold(Number(e.target.value) || 3)} />
3347
+ <div class="hint">Number of restarts within the window before a crash loop is declared.</div>
3348
+ </div>
3349
+ <div class="form-group">
3350
+ <label>Crash Loop Window (minutes)</label>
3351
+ <input type="number" value=${sentinelCrashLoopWindowMin} min="1" max="60"
3352
+ oninput=${(e) => setSentinelCrashLoopWindowMin(Number(e.target.value) || 5)} />
3353
+ <div class="hint">Time window in which the threshold is measured.</div>
3354
+ </div>
3355
+ </div>
3356
+ <div class="form-group" style="margin-bottom:12px">
3357
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3358
+ <input type="checkbox" checked=${sentinelRepairAgentEnabled}
3359
+ onchange=${(e) => setSentinelRepairAgentEnabled(e.target.checked)}
3360
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
3361
+ Enable Repair Agent
3362
+ </label>
3363
+ <div class="hint">On crash loop, run an AI repair agent to attempt automatic diagnosis and fix.</div>
3364
+ </div>
3365
+ ${sentinelRepairAgentEnabled && html`
3366
+ <div class="form-group">
3367
+ <label>Repair Agent Timeout (minutes)</label>
3368
+ <input type="number" value=${sentinelRepairTimeoutMin} min="1" max="120"
3369
+ oninput=${(e) => setSentinelRepairTimeoutMin(Number(e.target.value) || 15)} />
3370
+ </div>
3371
+ `}
3372
+ <//>
3373
+
3374
+ <${Section} title="🔁 Daemon Restart Policy">
3375
+ <p class="hint" style="margin-top:0;margin-bottom:16px">
3376
+ Controls how the bosun daemon process handles crashes and restarts.
3377
+ </p>
3378
+ <div class="executor-grid">
3379
+ <div class="form-group">
3380
+ <label>Restart Delay (ms)</label>
3381
+ <input type="number" value=${daemonRestartDelayMs} min="0"
3382
+ oninput=${(e) => setDaemonRestartDelayMs(Number(e.target.value) || 10000)} />
3383
+ <div class="hint">Delay before restarting after a crash. Default: 10 000 ms.</div>
3384
+ </div>
3385
+ <div class="form-group">
3386
+ <label>Max Restarts (0 = unlimited)</label>
3387
+ <input type="number" value=${daemonMaxRestarts} min="0"
3388
+ oninput=${(e) => setDaemonMaxRestarts(Number(e.target.value))} />
3389
+ <div class="hint">Maximum total restarts before giving up. 0 means no limit.</div>
3390
+ </div>
3391
+ <div class="form-group">
3392
+ <label>Max Instant-Crash Restarts</label>
3393
+ <input type="number" value=${daemonMaxInstantRestarts} min="1" max="20"
3394
+ oninput=${(e) => setDaemonMaxInstantRestarts(Number(e.target.value) || 3)} />
3395
+ <div class="hint">Max restarts allowed within the instant-crash window before stopping.</div>
3396
+ </div>
3397
+ <div class="form-group">
3398
+ <label>Instant-Crash Window (ms)</label>
3399
+ <input type="number" value=${daemonInstantCrashWindowMs} min="1000"
3400
+ oninput=${(e) => setDaemonInstantCrashWindowMs(Number(e.target.value) || 15000)} />
3401
+ <div class="hint">If bosun crashes faster than this window repeatedly, it's considered an instant-crash loop. Default: 15 000 ms.</div>
3402
+ </div>
3403
+ </div>
3404
+ <//>
3405
+
3406
+ <${Section} title="�🚀 Launch & Startup">
3254
3407
  <p class="hint" style="margin-top:0;margin-bottom:16px">
3255
3408
  Configure whether bosun starts automatically and whether a desktop shortcut is created.
3256
3409
  ${status?.startupStatus?.methodName ? html` Platform method: <strong>${status.startupStatus.methodName}</strong>.` : ""}
@@ -3386,11 +3539,17 @@ function App() {
3386
3539
  <tr><th>Workflow Parallel Branches</th><td>${workflowMaxConcurrentBranches}</td></tr>
3387
3540
  <tr><th>Auto-Start on Login</th><td>${autoStartEnabled ? "Enabled" : "Disabled"}</td></tr>
3388
3541
  <tr><th>Desktop Shortcut</th><td>${desktopShortcutEnabled ? "Installed" : "Not installed"}</td></tr>
3542
+ <tr><th>Sentinel / Watchdog</th><td>${sentinelAutoStart ? `Enabled${sentinelStrict ? " (strict)" : ""}` : "Disabled"}</td></tr>
3543
+ <tr><th>Crash Loop Threshold</th><td>${sentinelCrashLoopThreshold} restarts in ${sentinelCrashLoopWindowMin} min</td></tr>
3544
+ <tr><th>Repair Agent</th><td>${sentinelRepairAgentEnabled ? `Enabled (timeout ${sentinelRepairTimeoutMin} min)` : "Disabled"}</td></tr>
3545
+ <tr><th>Daemon Restart Delay</th><td>${daemonRestartDelayMs} ms</td></tr>
3546
+ <tr><th>Max Restarts</th><td>${daemonMaxRestarts === 0 ? "Unlimited" : daemonMaxRestarts}</td></tr>
3389
3547
  ` : html`
3390
3548
  <tr><th>Max Parallel</th><td>${maxParallel}</td></tr>
3391
3549
  <tr><th>Failover Strategy</th><td>${failoverStrategy}</td></tr>
3392
3550
  <tr><th>Auto-Start on Login</th><td>${autoStartEnabled ? "Enabled" : "Disabled"}</td></tr>
3393
3551
  <tr><th>Desktop Shortcut</th><td>${desktopShortcutEnabled ? "Installed" : "Not installed"}</td></tr>
3552
+ <tr><th>Sentinel / Watchdog</th><td>${sentinelAutoStart ? "Enabled" : "Disabled"}</td></tr>
3394
3553
  `}
3395
3554
  </tbody>
3396
3555
  </table>
@@ -1350,6 +1350,15 @@ function ServerConfigMode() {
1350
1350
  <!-- Voice Endpoints card-based editor (synced with /setup) -->
1351
1351
  ${activeCategory === "voice" && html`<${VoiceEndpointsEditor} />`}
1352
1352
 
1353
+ <!-- OpenAI Codex OAuth login card (voice category) -->
1354
+ ${activeCategory === "voice" && html`<${OpenAICodexLoginCard} />`}
1355
+
1356
+ <!-- Claude OAuth login card (voice category) -->
1357
+ ${activeCategory === "voice" && html`<${ClaudeLoginCard} />`}
1358
+
1359
+ <!-- Google Gemini OAuth login card (voice category) -->
1360
+ ${activeCategory === "voice" && html`<${GeminiLoginCard} />`}
1361
+
1353
1362
  <!-- Settings list for active category -->
1354
1363
  ${catDefs.length === 0
1355
1364
  ? html`
@@ -2008,10 +2017,11 @@ function VoiceEndpointsEditor() {
2008
2017
  const normalizeEp = useCallback((ep = {}, idx = 0) => ({
2009
2018
  _id: ep._id ?? `ep-${idx}-${Date.now()}`,
2010
2019
  name: String(ep.name || `endpoint-${idx + 1}`),
2011
- provider: ["azure", "openai"].includes(ep.provider) ? ep.provider : "azure",
2020
+ provider: ["azure", "openai", "claude", "gemini"].includes(ep.provider) ? ep.provider : "azure",
2012
2021
  endpoint: String(ep.endpoint || ""),
2013
2022
  deployment: String(ep.deployment || ""),
2014
2023
  model: String(ep.model || ""),
2024
+ visionModel: String(ep.visionModel || ""),
2015
2025
  apiKey: String(ep.apiKey || ""),
2016
2026
  voiceId: String(ep.voiceId || ""),
2017
2027
  role: ["primary", "backup"].includes(ep.role) ? ep.role : "primary",
@@ -2130,6 +2140,8 @@ function VoiceEndpointsEditor() {
2130
2140
  <select value=${ep.provider} onChange=${(e) => updateEndpoint(ep._id, "provider", e.target.value)}>
2131
2141
  <option value="azure">Azure OpenAI</option>
2132
2142
  <option value="openai">OpenAI</option>
2143
+ <option value="claude">Claude (Anthropic)</option>
2144
+ <option value="gemini">Google Gemini</option>
2133
2145
  </select>
2134
2146
  </div>
2135
2147
  <div>
@@ -2159,7 +2171,7 @@ function VoiceEndpointsEditor() {
2159
2171
  onInput=${(e) => updateEndpoint(ep._id, "endpoint", e.target.value)} />
2160
2172
  </div>
2161
2173
  <div style="grid-column:1/-1">
2162
- <div class="setting-row-label">Deployment Name</div>
2174
+ <div class="setting-row-label">Audio Model (Realtime)</div>
2163
2175
  <input type="text" value=${ep.deployment} placeholder="gpt-realtime-1.5"
2164
2176
  onInput=${(e) => updateEndpoint(ep._id, "deployment", e.target.value)} />
2165
2177
  <div class="meta-text" style="margin-top:3px">
@@ -2170,11 +2182,18 @@ function VoiceEndpointsEditor() {
2170
2182
  `}
2171
2183
  ${ep.provider === "openai" && html`
2172
2184
  <div style="grid-column:1/-1">
2173
- <div class="setting-row-label">Model</div>
2185
+ <div class="setting-row-label">Audio Model (Realtime)</div>
2174
2186
  <input type="text" value=${ep.model} placeholder="gpt-4o-realtime-preview"
2175
2187
  onInput=${(e) => updateEndpoint(ep._id, "model", e.target.value)} />
2176
2188
  </div>
2177
2189
  `}
2190
+ <div style="grid-column:1/-1">
2191
+ <div class="setting-row-label">Vision Model</div>
2192
+ <input type="text" value=${ep.visionModel}
2193
+ placeholder=${ep.provider === "azure" ? "gpt-4o" : ep.provider === "claude" ? "claude-opus-4-5" : ep.provider === "gemini" ? "gemini-2.5-flash" : "gpt-4o"}
2194
+ onInput=${(e) => updateEndpoint(ep._id, "visionModel", e.target.value)} />
2195
+ <div class="meta-text" style="margin-top:3px">Model used for screenshot / image analysis tasks.</div>
2196
+ </div>
2178
2197
  </div>
2179
2198
  </div>
2180
2199
  `)}
@@ -2188,6 +2207,191 @@ function VoiceEndpointsEditor() {
2188
2207
  `;
2189
2208
  }
2190
2209
 
2210
+ /* ═══════════════════════════════════════════════════════════════
2211
+ * _OAuthLoginCard — shared PKCE OAuth login card factory.
2212
+ * Instantiated as OpenAICodexLoginCard, ClaudeLoginCard, GeminiLoginCard.
2213
+ * ═══════════════════════════════════════════════════════════════ */
2214
+ function _OAuthLoginCard({ displayName, emoji, statusRoute, loginRoute, cancelRoute, logoutRoute, description, successMsg, signOutMsg }) {
2215
+ const [phase, setPhase] = useState("idle");
2216
+ const [authUrl, setAuthUrl] = useState("");
2217
+ const [error, setError] = useState("");
2218
+ const pollRef = useRef(null);
2219
+
2220
+ useEffect(() => {
2221
+ (async () => {
2222
+ try {
2223
+ const res = await apiFetch(statusRoute);
2224
+ if (res.ok) setPhase(res.status === "connected" ? "connected" : "idle");
2225
+ } catch { /* non-fatal */ }
2226
+ })();
2227
+ return () => { if (pollRef.current) clearTimeout(pollRef.current); };
2228
+ }, [statusRoute]);
2229
+
2230
+ async function startLogin() {
2231
+ setPhase("pending"); setError("");
2232
+ try {
2233
+ const res = await apiFetch(loginRoute, { method: "POST" });
2234
+ if (!res.ok) throw new Error(res.error || "Failed to start login");
2235
+ setAuthUrl(res.authUrl || "");
2236
+ beginPolling();
2237
+ } catch (err) { setError(err.message); setPhase("error"); }
2238
+ }
2239
+
2240
+ function beginPolling() {
2241
+ if (pollRef.current) clearTimeout(pollRef.current);
2242
+ async function tick() {
2243
+ try {
2244
+ const res = await apiFetch(statusRoute);
2245
+ if (!res.ok) { pollRef.current = setTimeout(tick, 2000); return; }
2246
+ if (res.status === "complete" || res.status === "connected") {
2247
+ pollRef.current = null; setPhase("complete");
2248
+ haptic("success"); showToast(successMsg, "success"); return;
2249
+ }
2250
+ if (res.status === "error") {
2251
+ pollRef.current = null;
2252
+ setError(res.result?.error || "Login failed"); setPhase("error"); return;
2253
+ }
2254
+ pollRef.current = setTimeout(tick, 2000);
2255
+ } catch { pollRef.current = setTimeout(tick, 3000); }
2256
+ }
2257
+ pollRef.current = setTimeout(tick, 2000);
2258
+ }
2259
+
2260
+ async function handleLogout() {
2261
+ try {
2262
+ await apiFetch(logoutRoute, { method: "POST" });
2263
+ setPhase("idle"); setAuthUrl("");
2264
+ haptic("medium"); showToast(signOutMsg, "info");
2265
+ } catch (err) { showToast(`Logout failed: ${err.message}`, "error"); }
2266
+ }
2267
+
2268
+ async function handleCancel() {
2269
+ if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; }
2270
+ try { await apiFetch(cancelRoute, { method: "POST" }); } catch { /* ignore */ }
2271
+ setPhase("idle"); setAuthUrl("");
2272
+ }
2273
+
2274
+ if (phase === "connected" || phase === "complete") {
2275
+ return html`
2276
+ <${Card}>
2277
+ <div style="display:flex;align-items:center;gap:10px;padding:4px 0">
2278
+ <span style="font-size:22px">${emoji}</span>
2279
+ <div style="flex:1;min-width:0">
2280
+ <div style="font-size:13px;font-weight:600;color:var(--text-primary)">${displayName} Connected</div>
2281
+ <div style="font-size:12px;color:var(--text-secondary);margin-top:2px">Signed in via OAuth. Token used for API access.</div>
2282
+ </div>
2283
+ <button class="btn btn-sm btn-secondary" onClick=${handleLogout}>Sign out</button>
2284
+ </div>
2285
+ <//>
2286
+ `;
2287
+ }
2288
+
2289
+ if (phase === "pending") {
2290
+ return html`
2291
+ <${Card}>
2292
+ <div style="text-align:center;padding:12px 0">
2293
+ <div style="font-size:12px;color:var(--text-secondary);margin-bottom:12px">
2294
+ A browser window should have opened. If not, open the link below:
2295
+ </div>
2296
+ ${authUrl && html`
2297
+ <button onClick=${() => { try { window.open(authUrl, "_blank"); } catch {} }}
2298
+ style="font-size:12px;word-break:break-all;cursor:pointer;
2299
+ background:var(--surface-1);border:1px solid var(--border-color,rgba(255,255,255,0.1));
2300
+ border-radius:6px;padding:8px 12px;color:var(--accent);text-decoration:underline;
2301
+ max-width:100%;text-align:left"
2302
+ >${authUrl}</button>
2303
+ `}
2304
+ <div style="font-size:12px;color:var(--text-hint);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:6px">
2305
+ <span class="spinner" style="width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%"></span>
2306
+ Waiting for you to sign in…
2307
+ </div>
2308
+ <button class="btn btn-sm" style="margin-top:12px;opacity:0.7" onClick=${handleCancel}>Cancel</button>
2309
+ </div>
2310
+ <//>
2311
+ `;
2312
+ }
2313
+
2314
+ if (phase === "error") {
2315
+ return html`
2316
+ <${Card}>
2317
+ <div style="text-align:center;padding:10px 0">
2318
+ <div style="font-size:13px;color:var(--color-error,#f87171);margin-bottom:10px">${error}</div>
2319
+ <button class="btn btn-sm btn-primary" onClick=${startLogin}>Try again</button>
2320
+ </div>
2321
+ <//>
2322
+ `;
2323
+ }
2324
+
2325
+ // idle
2326
+ return html`
2327
+ <${Card}>
2328
+ <div style="text-align:center;padding:16px 0">
2329
+ <div style="font-size:32px;margin-bottom:8px">${emoji}</div>
2330
+ <div style="font-size:15px;font-weight:600;margin-bottom:4px;color:var(--text-primary)">Sign in with ${displayName}</div>
2331
+ <div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;max-width:300px;margin-inline:auto;line-height:1.6">${description}</div>
2332
+ <button class="btn btn-primary" onClick=${startLogin} style="min-width:220px">Sign in with ${displayName}</button>
2333
+ </div>
2334
+ <//>
2335
+ `;
2336
+ }
2337
+
2338
+ /* ═══════════════════════════════════════════════════════════════
2339
+ * OpenAICodexLoginCard — "Sign in with OpenAI" (ChatGPT/Codex accounts)
2340
+ * Uses OAuth 2.0 PKCE flow via auth.openai.com — same flow as the
2341
+ * official Codex CLI and the ChatGPT desktop app.
2342
+ *
2343
+ * Allows ChatGPT Plus/Pro/Team subscribers to authenticate without
2344
+ * needing to create an API key.
2345
+ * ═══════════════════════════════════════════════════════════════ */
2346
+ function OpenAICodexLoginCard() {
2347
+ return html`<${_OAuthLoginCard}
2348
+ displayName="OpenAI"
2349
+ emoji="🤖"
2350
+ statusRoute="/api/voice/auth/openai/status"
2351
+ loginRoute="/api/voice/auth/openai/login"
2352
+ cancelRoute="/api/voice/auth/openai/cancel"
2353
+ logoutRoute="/api/voice/auth/openai/logout"
2354
+ description="Use your ChatGPT Plus, Pro, or Team subscription to access OpenAI Realtime Audio without managing API keys. Uses the same OAuth flow as the Codex CLI."
2355
+ successMsg="Signed in with OpenAI!"
2356
+ signOutMsg="Signed out from OpenAI"
2357
+ />`;
2358
+ }
2359
+
2360
+ /* ═══════════════════════════════════════════════════════════════
2361
+ * ClaudeLoginCard — OAuth PKCE for Anthropic Claude
2362
+ * ═══════════════════════════════════════════════════════════════ */
2363
+ function ClaudeLoginCard() {
2364
+ return html`<${_OAuthLoginCard}
2365
+ displayName="Claude"
2366
+ emoji="🧠"
2367
+ statusRoute="/api/voice/auth/claude/status"
2368
+ loginRoute="/api/voice/auth/claude/login"
2369
+ cancelRoute="/api/voice/auth/claude/cancel"
2370
+ logoutRoute="/api/voice/auth/claude/logout"
2371
+ description="Sign in with your Claude.ai account to use Claude models for vision and analysis tasks. Uses the same OAuth PKCE flow as the official Claude desktop app."
2372
+ successMsg="Signed in with Claude!"
2373
+ signOutMsg="Signed out from Claude"
2374
+ />`;
2375
+ }
2376
+
2377
+ /* ═══════════════════════════════════════════════════════════════
2378
+ * GeminiLoginCard — OAuth PKCE for Google Gemini
2379
+ * ═══════════════════════════════════════════════════════════════ */
2380
+ function GeminiLoginCard() {
2381
+ return html`<${_OAuthLoginCard}
2382
+ displayName="Google Gemini"
2383
+ emoji="✨"
2384
+ statusRoute="/api/voice/auth/gemini/status"
2385
+ loginRoute="/api/voice/auth/gemini/login"
2386
+ cancelRoute="/api/voice/auth/gemini/cancel"
2387
+ logoutRoute="/api/voice/auth/gemini/logout"
2388
+ description="Sign in with your Google account to access Gemini models for vision and multimodal tasks. Uses Google OAuth with offline access for persistent refresh tokens."
2389
+ successMsg="Signed in with Google Gemini!"
2390
+ signOutMsg="Signed out from Google Gemini"
2391
+ />`;
2392
+ }
2393
+
2394
+
2191
2395
  /* ═══════════════════════════════════════════════════════════════
2192
2396
  * GitHubDeviceFlowCard — "Sign in with GitHub" (like VS Code / Roo Code)
2193
2397
  * Uses OAuth Device Flow: no public URL, no callback needed.
package/ui-server.mjs CHANGED
@@ -9254,6 +9254,7 @@ async function handleApi(req, res, url) {
9254
9254
  if (ep.endpoint) out.endpoint = String(ep.endpoint);
9255
9255
  if (ep.deployment) out.deployment = String(ep.deployment);
9256
9256
  if (ep.model) out.model = String(ep.model);
9257
+ if (ep.visionModel) out.visionModel = String(ep.visionModel);
9257
9258
  if (ep.apiKey) out.apiKey = String(ep.apiKey);
9258
9259
  if (ep.voiceId) out.voiceId = String(ep.voiceId);
9259
9260
  if (ep.role) out.role = String(ep.role);
@@ -9274,6 +9275,164 @@ async function handleApi(req, res, url) {
9274
9275
  return;
9275
9276
  }
9276
9277
 
9278
+ // ── OpenAI Codex OAuth routes ─────────────────────────────────────────────
9279
+
9280
+ // GET /api/voice/auth/openai/status — token presence + pending login state
9281
+ if (path === "/api/voice/auth/openai/status" && req.method === "GET") {
9282
+ try {
9283
+ const { getOpenAILoginStatus } = await import("./voice-auth-manager.mjs");
9284
+ jsonResponse(res, 200, { ok: true, ...getOpenAILoginStatus() });
9285
+ } catch (err) {
9286
+ jsonResponse(res, 500, { ok: false, error: err.message });
9287
+ }
9288
+ return;
9289
+ }
9290
+
9291
+ // POST /api/voice/auth/openai/login — start PKCE login, open browser
9292
+ if (path === "/api/voice/auth/openai/login" && req.method === "POST") {
9293
+ try {
9294
+ const { startOpenAICodexLogin } = await import("./voice-auth-manager.mjs");
9295
+ const { authUrl } = startOpenAICodexLogin();
9296
+ jsonResponse(res, 200, { ok: true, authUrl });
9297
+ } catch (err) {
9298
+ jsonResponse(res, 500, { ok: false, error: err.message });
9299
+ }
9300
+ return;
9301
+ }
9302
+
9303
+ // POST /api/voice/auth/openai/cancel — cancel pending login
9304
+ if (path === "/api/voice/auth/openai/cancel" && req.method === "POST") {
9305
+ try {
9306
+ const { cancelOpenAILogin } = await import("./voice-auth-manager.mjs");
9307
+ cancelOpenAILogin();
9308
+ jsonResponse(res, 200, { ok: true });
9309
+ } catch (err) {
9310
+ jsonResponse(res, 500, { ok: false, error: err.message });
9311
+ }
9312
+ return;
9313
+ }
9314
+
9315
+ // POST /api/voice/auth/openai/logout — remove stored token
9316
+ if (path === "/api/voice/auth/openai/logout" && req.method === "POST") {
9317
+ try {
9318
+ const { logoutOpenAI } = await import("./voice-auth-manager.mjs");
9319
+ const result = logoutOpenAI();
9320
+ broadcastUiEvent(["settings", "voice"], "invalidate", {
9321
+ reason: "openai-oauth-logout",
9322
+ });
9323
+ jsonResponse(res, 200, { ok: true, ...result });
9324
+ } catch (err) {
9325
+ jsonResponse(res, 500, { ok: false, error: err.message });
9326
+ }
9327
+ return;
9328
+ }
9329
+
9330
+ // POST /api/voice/auth/openai/refresh — exchange refresh_token for new access_token
9331
+ if (path === "/api/voice/auth/openai/refresh" && req.method === "POST") {
9332
+ try {
9333
+ const { refreshOpenAICodexToken } = await import("./voice-auth-manager.mjs");
9334
+ await refreshOpenAICodexToken();
9335
+ jsonResponse(res, 200, { ok: true });
9336
+ } catch (err) {
9337
+ jsonResponse(res, 500, { ok: false, error: err.message });
9338
+ }
9339
+ return;
9340
+ }
9341
+
9342
+ // ── Claude OAuth routes ────────────────────────────────────────────────────
9343
+
9344
+ if (path === "/api/voice/auth/claude/status" && req.method === "GET") {
9345
+ try {
9346
+ const { getClaudeLoginStatus } = await import("./voice-auth-manager.mjs");
9347
+ jsonResponse(res, 200, { ok: true, ...getClaudeLoginStatus() });
9348
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9349
+ return;
9350
+ }
9351
+
9352
+ if (path === "/api/voice/auth/claude/login" && req.method === "POST") {
9353
+ try {
9354
+ const { startClaudeLogin } = await import("./voice-auth-manager.mjs");
9355
+ const { authUrl } = startClaudeLogin();
9356
+ jsonResponse(res, 200, { ok: true, authUrl });
9357
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9358
+ return;
9359
+ }
9360
+
9361
+ if (path === "/api/voice/auth/claude/cancel" && req.method === "POST") {
9362
+ try {
9363
+ const { cancelClaudeLogin } = await import("./voice-auth-manager.mjs");
9364
+ cancelClaudeLogin();
9365
+ jsonResponse(res, 200, { ok: true });
9366
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9367
+ return;
9368
+ }
9369
+
9370
+ if (path === "/api/voice/auth/claude/logout" && req.method === "POST") {
9371
+ try {
9372
+ const { logoutClaude } = await import("./voice-auth-manager.mjs");
9373
+ const result = logoutClaude();
9374
+ broadcastUiEvent(["settings", "voice"], "invalidate", { reason: "claude-oauth-logout" });
9375
+ jsonResponse(res, 200, { ok: true, ...result });
9376
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9377
+ return;
9378
+ }
9379
+
9380
+ if (path === "/api/voice/auth/claude/refresh" && req.method === "POST") {
9381
+ try {
9382
+ const { refreshClaudeToken } = await import("./voice-auth-manager.mjs");
9383
+ await refreshClaudeToken();
9384
+ jsonResponse(res, 200, { ok: true });
9385
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9386
+ return;
9387
+ }
9388
+
9389
+ // ── Google Gemini OAuth routes ─────────────────────────────────────────────
9390
+
9391
+ if (path === "/api/voice/auth/gemini/status" && req.method === "GET") {
9392
+ try {
9393
+ const { getGeminiLoginStatus } = await import("./voice-auth-manager.mjs");
9394
+ jsonResponse(res, 200, { ok: true, ...getGeminiLoginStatus() });
9395
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9396
+ return;
9397
+ }
9398
+
9399
+ if (path === "/api/voice/auth/gemini/login" && req.method === "POST") {
9400
+ try {
9401
+ const { startGeminiLogin } = await import("./voice-auth-manager.mjs");
9402
+ const { authUrl } = startGeminiLogin();
9403
+ jsonResponse(res, 200, { ok: true, authUrl });
9404
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9405
+ return;
9406
+ }
9407
+
9408
+ if (path === "/api/voice/auth/gemini/cancel" && req.method === "POST") {
9409
+ try {
9410
+ const { cancelGeminiLogin } = await import("./voice-auth-manager.mjs");
9411
+ cancelGeminiLogin();
9412
+ jsonResponse(res, 200, { ok: true });
9413
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9414
+ return;
9415
+ }
9416
+
9417
+ if (path === "/api/voice/auth/gemini/logout" && req.method === "POST") {
9418
+ try {
9419
+ const { logoutGemini } = await import("./voice-auth-manager.mjs");
9420
+ const result = logoutGemini();
9421
+ broadcastUiEvent(["settings", "voice"], "invalidate", { reason: "gemini-oauth-logout" });
9422
+ jsonResponse(res, 200, { ok: true, ...result });
9423
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9424
+ return;
9425
+ }
9426
+
9427
+ if (path === "/api/voice/auth/gemini/refresh" && req.method === "POST") {
9428
+ try {
9429
+ const { refreshGeminiToken } = await import("./voice-auth-manager.mjs");
9430
+ await refreshGeminiToken();
9431
+ jsonResponse(res, 200, { ok: true });
9432
+ } catch (err) { jsonResponse(res, 500, { ok: false, error: err.message }); }
9433
+ return;
9434
+ }
9435
+
9277
9436
  // GET /api/voice/sdk-config — SDK-first configuration for client
9278
9437
  if (path === "/api/voice/sdk-config" && req.method === "GET") {
9279
9438
  try {
@@ -1,4 +1,7 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { exec as childExec } from "node:child_process";
1
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
2
5
  import { homedir } from "node:os";
3
6
  import { dirname, join } from "node:path";
4
7
 
@@ -162,3 +165,350 @@ export function saveVoiceOAuthToken(provider, payload = {}) {
162
165
  export function getVoiceAuthStatePath() {
163
166
  return VOICE_AUTH_STATE_PATH;
164
167
  }
168
+
169
+ // ── Generic OAuth PKCE provider registry ─────────────────────────────────────
170
+ //
171
+ // Provider configs reverse-engineered from official CLI tools:
172
+ // • openai — https://github.com/openai/codex + opencode/issues/3281
173
+ // • claude — github.com/XiaoConstantine/dspy-go/pkg/llms (Claude Code client)
174
+ // • gemini — google-gemini/gemini-cli (Google Gemini CLI public client)
175
+ //
176
+ // All use OAuth 2.0 Authorization Code + PKCE (RFC 7636) with no client secret
177
+ // (public clients per RFC 8252 §8.4).
178
+ const OAUTH_PROVIDERS = {
179
+ openai: {
180
+ clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
181
+ authorizeUrl: "https://auth.openai.com/oauth/authorize",
182
+ tokenUrl: "https://auth.openai.com/oauth/token",
183
+ redirectUri: "http://localhost:1455/auth/callback",
184
+ port: 1455,
185
+ scopes: "openid profile email offline_access",
186
+ extraParams: {
187
+ id_token_add_organizations: "true",
188
+ codex_cli_simplified_flow: "true",
189
+ },
190
+ accentColor: "#10a37f",
191
+ },
192
+ claude: {
193
+ // Claude Code public client — same one used by `claude auth login`
194
+ clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
195
+ authorizeUrl: "https://claude.ai/oauth/authorize",
196
+ tokenUrl: "https://console.anthropic.com/v1/oauth/token",
197
+ redirectUri: "http://localhost:10001/auth/callback",
198
+ port: 10001,
199
+ scopes: "openid profile email offline_access",
200
+ extraParams: {},
201
+ accentColor: "#d97706",
202
+ },
203
+ gemini: {
204
+ // Gemini CLI public client (google-gemini/gemini-cli open-source)
205
+ clientId: "681255809395-ets0jcnv5ak5mca0r35k1ofb3aqrdh28.apps.googleusercontent.com",
206
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
207
+ tokenUrl: "https://oauth2.googleapis.com/token",
208
+ redirectUri: "http://localhost:10002/auth/callback",
209
+ port: 10002,
210
+ scopes: [
211
+ "https://www.googleapis.com/auth/generative-language",
212
+ "https://www.googleapis.com/auth/cloud-platform",
213
+ "openid",
214
+ "email",
215
+ "profile",
216
+ ].join(" "),
217
+ extraParams: {
218
+ // Offline access (refresh token) + force consent screen to re-issue refresh_token
219
+ access_type: "offline",
220
+ prompt: "consent",
221
+ },
222
+ accentColor: "#1a73e8",
223
+ },
224
+ };
225
+
226
+ // Module-scope per-provider pending login state (never inside a function — hard rule).
227
+ const _providerPendingLogin = new Map();
228
+
229
+ // ── PKCE helpers ──────────────────────────────────────────────────────────────
230
+
231
+ function _generateCodeVerifier() {
232
+ return randomBytes(32).toString("base64url");
233
+ }
234
+
235
+ function _computeCodeChallenge(verifier) {
236
+ return createHash("sha256").update(verifier).digest("base64url");
237
+ }
238
+
239
+ function _generateState() {
240
+ return randomBytes(16).toString("hex");
241
+ }
242
+
243
+ // ── Generic token exchange ────────────────────────────────────────────────────
244
+
245
+ async function _exchangeCode(code, codeVerifier, cfg) {
246
+ const params = new URLSearchParams({
247
+ grant_type: "authorization_code",
248
+ code,
249
+ client_id: cfg.clientId,
250
+ redirect_uri: cfg.redirectUri,
251
+ code_verifier: codeVerifier,
252
+ });
253
+ const res = await fetch(cfg.tokenUrl, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
256
+ body: params.toString(),
257
+ });
258
+ if (!res.ok) {
259
+ const text = await res.text().catch(() => res.status.toString());
260
+ throw new Error(`Token exchange failed (${res.status}): ${text}`);
261
+ }
262
+ return res.json();
263
+ }
264
+
265
+ // ── Generic one-shot callback server ─────────────────────────────────────────
266
+
267
+ function _createCallbackServer(provider, cfg, expectedState, codeVerifier) {
268
+ const port = cfg.port;
269
+ const server = createServer((req, res) => {
270
+ let url;
271
+ try {
272
+ url = new URL(req.url, `http://localhost:${port}`);
273
+ } catch {
274
+ res.writeHead(400).end("Bad request");
275
+ return;
276
+ }
277
+
278
+ if (url.pathname !== "/auth/callback") {
279
+ res.writeHead(404).end("Not found");
280
+ return;
281
+ }
282
+
283
+ const code = url.searchParams.get("code");
284
+ const state = url.searchParams.get("state");
285
+ const error = url.searchParams.get("error");
286
+ const errorDescription = url.searchParams.get("error_description") || error || "Unknown error";
287
+ const pending = _providerPendingLogin.get(provider);
288
+
289
+ if (error) {
290
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
291
+ res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;padding:40px">
292
+ <h2>Sign-in failed</h2><p>${errorDescription}</p><p>You can close this window.</p>
293
+ </body></html>`);
294
+ if (pending) { pending.status = "error"; pending.result = { error: errorDescription }; }
295
+ setTimeout(() => server.close(), 500);
296
+ return;
297
+ }
298
+
299
+ if (!code || state !== expectedState) {
300
+ res.writeHead(400).end("Invalid callback");
301
+ if (pending) { pending.status = "error"; pending.result = { error: "state_mismatch" }; }
302
+ setTimeout(() => server.close(), 500);
303
+ return;
304
+ }
305
+
306
+ _exchangeCode(code, codeVerifier, cfg)
307
+ .then((tokenData) => {
308
+ const expiresAt = tokenData.expires_in
309
+ ? new Date(Date.now() + Number(tokenData.expires_in) * 1000).toISOString()
310
+ : null;
311
+ saveVoiceOAuthToken(provider, {
312
+ accessToken: tokenData.access_token,
313
+ refreshToken: tokenData.refresh_token || null,
314
+ expiresAt,
315
+ tokenType: tokenData.token_type || "Bearer",
316
+ });
317
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
318
+ res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;padding:40px;text-align:center">
319
+ <h2 style="color:${cfg.accentColor}">✓ Signed in successfully</h2>
320
+ <p>You can close this window and return to Bosun.</p>
321
+ </body></html>`);
322
+ const p = _providerPendingLogin.get(provider);
323
+ if (p) { p.status = "complete"; p.result = { ok: true }; }
324
+ })
325
+ .catch((err) => {
326
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
327
+ res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;padding:40px">
328
+ <h2>Token exchange failed</h2><p>${err.message}</p><p>You can close this window.</p>
329
+ </body></html>`);
330
+ const p = _providerPendingLogin.get(provider);
331
+ if (p) { p.status = "error"; p.result = { error: err.message }; }
332
+ })
333
+ .finally(() => { setTimeout(() => server.close(), 1000); });
334
+ });
335
+
336
+ server.on("error", (err) => {
337
+ const p = _providerPendingLogin.get(provider);
338
+ if (p?.status === "pending") { p.status = "error"; p.result = { error: err.message }; }
339
+ });
340
+
341
+ server.listen(port, "localhost");
342
+ return server;
343
+ }
344
+
345
+ // ── Open browser cross-platform ───────────────────────────────────────────────
346
+
347
+ function _openBrowser(url) {
348
+ const cmd =
349
+ process.platform === "win32"
350
+ ? `start "" "${url}"`
351
+ : process.platform === "darwin"
352
+ ? `open "${url}"`
353
+ : `xdg-open "${url}"`;
354
+ childExec(cmd, () => { /* non-fatal: URL already returned to caller */ });
355
+ }
356
+
357
+ // ── Generic provider login functions ────────────────────────────────────────
358
+
359
+ function _startProviderLogin(provider) {
360
+ const cfg = OAUTH_PROVIDERS[provider];
361
+ if (!cfg) throw new Error(`Unknown OAuth provider: ${provider}`);
362
+
363
+ // Cancel any existing login for this provider
364
+ _cancelProviderLogin(provider);
365
+
366
+ const codeVerifier = _generateCodeVerifier();
367
+ const codeChallenge = _computeCodeChallenge(codeVerifier);
368
+ const state = _generateState();
369
+
370
+ const params = new URLSearchParams({
371
+ response_type: "code",
372
+ client_id: cfg.clientId,
373
+ redirect_uri: cfg.redirectUri,
374
+ scope: cfg.scopes,
375
+ state,
376
+ code_challenge: codeChallenge,
377
+ code_challenge_method: "S256",
378
+ ...cfg.extraParams,
379
+ });
380
+
381
+ const authUrl = `${cfg.authorizeUrl}?${params.toString()}`;
382
+ const server = _createCallbackServer(provider, cfg, state, codeVerifier);
383
+
384
+ _providerPendingLogin.set(provider, {
385
+ state,
386
+ codeVerifier,
387
+ server,
388
+ status: "pending",
389
+ result: null,
390
+ startedAt: Date.now(),
391
+ });
392
+
393
+ try { _openBrowser(authUrl); } catch { /* ignore */ }
394
+
395
+ return { authUrl };
396
+ }
397
+
398
+ function _getProviderLoginStatus(provider) {
399
+ const hasToken = Boolean(resolveVoiceOAuthToken(provider, false));
400
+ const pending = _providerPendingLogin.get(provider);
401
+ if (!pending) {
402
+ return { status: hasToken ? "connected" : "idle", hasToken };
403
+ }
404
+ const elapsed = Date.now() - pending.startedAt;
405
+ if (elapsed > 5 * 60 * 1000 && pending.status === "pending") {
406
+ _cancelProviderLogin(provider);
407
+ return { status: "idle", hasToken };
408
+ }
409
+ return { status: pending.status, result: pending.result || null, hasToken };
410
+ }
411
+
412
+ function _cancelProviderLogin(provider) {
413
+ const pending = _providerPendingLogin.get(provider);
414
+ if (pending?.server) {
415
+ try { pending.server.close(); } catch { /* ignore */ }
416
+ }
417
+ _providerPendingLogin.delete(provider);
418
+ }
419
+
420
+ function _logoutProvider(provider) {
421
+ const curr = getCachedState(true);
422
+ if (!curr?.providers?.[provider]) return { ok: true, wasLoggedIn: false };
423
+ const next = {
424
+ ...curr,
425
+ providers: { ...(curr.providers || {}) },
426
+ };
427
+ delete next.providers[provider];
428
+ mkdirSync(dirname(VOICE_AUTH_STATE_PATH), { recursive: true });
429
+ writeFileSync(VOICE_AUTH_STATE_PATH, JSON.stringify(next, null, 2));
430
+ _cachedState = next;
431
+ _cachedStateAt = Date.now();
432
+ return { ok: true, wasLoggedIn: true };
433
+ }
434
+
435
+ async function _refreshProviderToken(provider) {
436
+ const cfg = OAUTH_PROVIDERS[provider];
437
+ if (!cfg) throw new Error(`Unknown OAuth provider: ${provider}`);
438
+
439
+ const state = getCachedState(true);
440
+ const providerData = state?.providers?.[provider] || {};
441
+ const refreshToken = providerData.refreshToken || providerData.refresh_token;
442
+ if (!refreshToken) throw new Error(`No refresh token stored for ${provider}`);
443
+
444
+ const params = new URLSearchParams({
445
+ grant_type: "refresh_token",
446
+ refresh_token: refreshToken,
447
+ client_id: cfg.clientId,
448
+ ...(cfg.extraParams?.access_type ? { access_type: cfg.extraParams.access_type } : {}),
449
+ });
450
+
451
+ const res = await fetch(cfg.tokenUrl, {
452
+ method: "POST",
453
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
454
+ body: params.toString(),
455
+ });
456
+
457
+ if (!res.ok) {
458
+ const text = await res.text().catch(() => res.status.toString());
459
+ throw new Error(`Token refresh failed (${res.status}): ${text}`);
460
+ }
461
+
462
+ const tokenData = await res.json();
463
+ const expiresAt = tokenData.expires_in
464
+ ? new Date(Date.now() + Number(tokenData.expires_in) * 1000).toISOString()
465
+ : null;
466
+
467
+ saveVoiceOAuthToken(provider, {
468
+ accessToken: tokenData.access_token,
469
+ refreshToken: tokenData.refresh_token || refreshToken,
470
+ expiresAt,
471
+ tokenType: tokenData.token_type || "Bearer",
472
+ });
473
+
474
+ return tokenData;
475
+ }
476
+
477
+ // ── Public API — OpenAI ──────────────────────────────────────────────────────
478
+
479
+ /** Start the OpenAI Codex OAuth PKCE flow (same as Codex CLI / ChatGPT app). */
480
+ export function startOpenAICodexLogin() { return _startProviderLogin("openai"); }
481
+ /** Poll the status of an in-progress OpenAI login. */
482
+ export function getOpenAILoginStatus() { return _getProviderLoginStatus("openai"); }
483
+ /** Cancel an in-progress OpenAI login. */
484
+ export function cancelOpenAILogin() { return _cancelProviderLogin("openai"); }
485
+ /** Remove the stored OpenAI OAuth token. */
486
+ export function logoutOpenAI() { return _logoutProvider("openai"); }
487
+ /** Refresh an expired OpenAI access token using the stored refresh_token. */
488
+ export async function refreshOpenAICodexToken() { return _refreshProviderToken("openai"); }
489
+
490
+ // ── Public API — Claude ──────────────────────────────────────────────────────
491
+
492
+ /** Start the Anthropic Claude OAuth PKCE flow (same as `claude auth login`). */
493
+ export function startClaudeLogin() { return _startProviderLogin("claude"); }
494
+ /** Poll the status of an in-progress Claude login. */
495
+ export function getClaudeLoginStatus() { return _getProviderLoginStatus("claude"); }
496
+ /** Cancel an in-progress Claude login. */
497
+ export function cancelClaudeLogin() { return _cancelProviderLogin("claude"); }
498
+ /** Remove the stored Claude OAuth token. */
499
+ export function logoutClaude() { return _logoutProvider("claude"); }
500
+ /** Refresh an expired Claude access token. */
501
+ export async function refreshClaudeToken() { return _refreshProviderToken("claude"); }
502
+
503
+ // ── Public API — Google Gemini ───────────────────────────────────────────────
504
+
505
+ /** Start the Google Gemini OAuth PKCE flow (same as gemini-cli `gemini auth login`). */
506
+ export function startGeminiLogin() { return _startProviderLogin("gemini"); }
507
+ /** Poll the status of an in-progress Gemini login. */
508
+ export function getGeminiLoginStatus() { return _getProviderLoginStatus("gemini"); }
509
+ /** Cancel an in-progress Gemini login. */
510
+ export function cancelGeminiLogin() { return _cancelProviderLogin("gemini"); }
511
+ /** Remove the stored Gemini OAuth token. */
512
+ export function logoutGemini() { return _logoutProvider("gemini"); }
513
+ /** Refresh an expired Gemini access token. */
514
+ export async function refreshGeminiToken() { return _refreshProviderToken("gemini"); }
package/voice-relay.mjs CHANGED
@@ -949,6 +949,9 @@ async function createAzureEphemeralToken(cfg, toolDefinitions = [], callContext
949
949
  }
950
950
 
951
951
  const sessionConfig = {
952
+ // GA protocol (gpt-realtime-1.5 etc.) requires type: "realtime" in the POST body.
953
+ // Preview protocol does not support this field — omit it to avoid 400s.
954
+ ...(isAzureGaProtocol(deployment) ? { type: "realtime" } : {}),
952
955
  model: deployment,
953
956
  voice: voiceId,
954
957
  instructions,