fix: authenticate event-stream WebSocket before connecting

The global event-stream WebSocket opened before a valid oc_url_token was
minted, so the upgrade failed auth ("no valid credentials available") in
packaged builds with a UI password. The resulting reconnect storm churned
the sync store and made session status flicker busy<->idle. Await the URL
auth token before connecting (a WS upgrade can't send a bearer header like
SSE does) and drop a rejected token on pre-ready close so the next attempt
re-mints a fresh one.

Also harden /session/status reconciliation: the watchdog poll is now
monotonic (only confirms/raises active status, never blindly lowers a
busy/retry session to idle on a transient or misscoped snapshot). Idle is
applied only by the authoritative reconnect/escalation resync, which trusts
the live server snapshot as the source of truth. Add a Help -> Toggle
Developer Tools menu item so production builds can open the console.
This commit is contained in:
Bohdan Triapitsyn
2026-06-14 19:48:07 +03:00
parent 94ca3fda04
commit 9f06224151
5 changed files with 224 additions and 46 deletions
+31
View File
@@ -15,6 +15,7 @@
import type { Event, OpencodeClient, SessionStatus } from "@opencode-ai/sdk/v2/client"
import { opencodeClient } from "@/lib/opencode/client"
import { getRuntimeUrlResolver } from "@/lib/runtime-url"
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from "@/lib/runtime-auth"
import { syncDebug } from "./debug"
export type QueuedEvent = {
@@ -529,6 +530,28 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline {
}
const runWsAttempt = async (signal: AbortSignal) => {
// A WebSocket upgrade can't carry an Authorization header, so it
// authenticates purely via the oc_url_token query param. The sync token
// getter returns "" while the token is unminted or inside its expiry skew
// window, which would open the socket WITHOUT credentials — the server then
// rejects it ("HTTP Authentication failed; no valid credentials available")
// and the resulting reconnect storm churns the sync store (transient
// status-missing → idle flicker). Mint/await a valid token BEFORE
// connecting. (SSE avoids this: the SDK fetch sends the bearer header.)
try {
await refreshRuntimeUrlAuthToken()
} catch (error) {
const wrapped = error instanceof Error ? error : new Error("Message stream WebSocket auth token unavailable")
if (transport === "auto") {
wsFallbackUntil = Date.now() + WS_FALLBACK_WINDOW_MS
;(wrapped as Error & { code?: string }).code = "WS_FALLBACK"
}
;(wrapped as Error & { reason?: string }).reason = "ws_auth_token_unavailable"
throw wrapped
}
if (signal.aborted) {
throw new DOMException("Aborted", "AbortError")
}
await new Promise<void>((resolve, reject) => {
let settled = false
let opened = false
@@ -677,6 +700,14 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline {
? `ws_closed:code=${event?.code ?? "?"}`
: "ws_closed_before_ready"
// Closed before the socket ever opened → the server rejected the
// upgrade, typically an auth failure on the oc_url_token. Drop the
// cached token so the next attempt mints a fresh one instead of
// replaying a token the server won't accept (which would loop).
if (!opened) {
clearRuntimeUrlAuthToken()
}
// If the WS stream connects (ready) but then drops quickly, prefer SSE for a while.
// This avoids tight reconnect loops with repeated console spam.
const livedMs = readyAt > 0 ? Date.now() - readyAt : 0