diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index ca188e7e..3e97f541 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -537,9 +537,37 @@ const readDesktopLocalClientToken = () => { return sanitizeClientTokenForStorage(readSettingsRoot().desktopLocalClientToken) || ''; }; +const isMachineLocalHostname = (hostname) => { + const clean = String(hostname || '').replace(/^\[|\]$/g, ''); + if (!clean) return false; + if (clean === 'localhost' || clean === '127.0.0.1' || clean === '::1' || clean === '0.0.0.0' || clean === '::') { + return true; + } + try { + return Object.values(os.networkInterfaces()).some((entries) => + (entries || []).some((entry) => entry?.address === clean)); + } catch { + return false; + } +}; + const isLocalRuntimeUrl = (targetUrl) => { const localUrl = state.sidecarUrl || state.localOrigin || ''; - return Boolean(localUrl && sameOrigin(targetUrl, localUrl)); + if (!localUrl) return false; + if (sameOrigin(targetUrl, localUrl)) return true; + // The embedded server bound to 0.0.0.0 for LAN access is still THIS + // machine's server when addressed via any of its own interfaces on the same + // port — the minted client token must carry the desktop-local kind, or the + // server's client-create gate rejects it (the "Local — Auth required" + + // unreachable-screen regression). + try { + const target = new URL(targetUrl); + const local = new URL(localUrl); + const portOf = (url) => url.port || (url.protocol === 'https:' ? '443' : '80'); + return portOf(target) === portOf(local) && isMachineLocalHostname(target.hostname); + } catch { + return false; + } }; const readDesktopHostsConfig = () => { diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 8dbe460b..00c7c336 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -51,11 +51,30 @@ const shouldIssueDesktopClientToken = (): boolean => { return isDesktopShell(); }; +const isLoopbackHostname = (hostname: string): boolean => { + const clean = hostname.replace(/^\[|\]$/g, ''); + return clean === 'localhost' || clean === '127.0.0.1' || clean === '::1'; +}; + const isLocalDesktopRuntime = (): boolean => { if (!isDesktopShell()) return false; - const apiBaseUrl = getRuntimeApiBaseUrl(); const localOrigin = readLocalOrigin(); - return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl)); + if (!localOrigin) return false; + // An empty api base means same-origin requests against the page itself — + // which on desktop IS the embedded local server. Requiring an exact origin + // match here used to leave local client tokens untagged (no desktop-local + // clientKind), and the server's client-create gate then 403'd them. + const apiBaseUrl = getRuntimeApiBaseUrl(); + const effectiveTarget = apiBaseUrl || (typeof window !== 'undefined' ? window.location.origin : ''); + if (sameOrigin(localOrigin, effectiveTarget)) return true; + // Loopback aliases (localhost vs 127.0.0.1) still address this machine's + // own server. + try { + const normalized = normalizeHostUrl(effectiveTarget); + return Boolean(normalized && isLoopbackHostname(new URL(normalized).hostname)); + } catch { + return false; + } }; const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => { diff --git a/packages/web/server/lib/client-auth/remote-clients.js b/packages/web/server/lib/client-auth/remote-clients.js index 7b87deb1..e16f391b 100644 --- a/packages/web/server/lib/client-auth/remote-clients.js +++ b/packages/web/server/lib/client-auth/remote-clients.js @@ -135,6 +135,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP }; if (normalizedDedupeKey) { store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey); + // Migrate pre-clientKind desktop tokens: a deduped, kind-tagged mint + // supersedes legacy records with the same label that carry neither a + // kind nor a dedupe key — those tokens can no longer pass the + // desktop-local client-create gate and would otherwise linger forever. + if (client.clientKind) { + store.clients = store.clients.filter((entry) => + !(entry.label === client.label && !entry.clientKind && !entry.dedupeKey)); + } } store.clients.push(client); await writeStore(store);