{"record":{"id":"e1b4c4a1566926d2","repo":"tinyhumansai/openhuman","slug":"openrouter-oauth-requires-the-desktop-app-use-an","errorCode":null,"errorMessage":"OpenRouter OAuth requires the desktop app. Use an API key instead.","messagePattern":"OpenRouter OAuth requires the desktop app\\. Use an API key instead\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/utils/openrouterOAuth.ts","lineNumber":126,"sourceCode":"  // Preserve the port the loopback listener actually bound to (carried in\n  // redirectUri): when the requested port is busy, the Tauri command falls back\n  // to an OS-assigned ephemeral port, so hardcoding OPENROUTER_LOOPBACK_PORT here\n  // sent OpenRouter a callback_url pointing at the wrong port. The PKCE\n  // callback_url is per-request, so the dynamic port is valid (this matches the\n  // sibling OAuthProviderButton flow, which trusts the bound port).\n  parsed.hostname = 'localhost';\n  return parsed.toString();\n}\n\nexport async function connectOpenRouterViaOAuth(deps: OpenRouterOAuthDeps = {}): Promise<string> {\n  const startLoopbackListener = deps.startLoopbackListener ?? startLoopbackOauthListener;\n  const openExternalUrl = deps.openExternalUrl ?? openUrl;\n  const fetchImpl = deps.fetchImpl ?? fetch;\n  const signal = deps.signal;\n\n  const loopback = await startLoopbackListener({ port: OPENROUTER_LOOPBACK_PORT });\n  if (!loopback) {\n    throw new Error('OpenRouter OAuth requires the desktop app. Use an API key instead.');\n  }\n\n  if (signal?.aborted) {\n    await loopback.cancel();\n    throw new Error('OpenRouter OAuth was cancelled.');\n  }\n\n  const verifier = randomVerifier();\n  const challenge = await createCodeChallenge(verifier);\n  const authUrl = new URL(OPENROUTER_AUTH_URL);\n  authUrl.searchParams.set('callback_url', toOpenRouterCallbackUrl(loopback.redirectUri));\n  authUrl.searchParams.set('code_challenge', challenge);\n  authUrl.searchParams.set('code_challenge_method', PKCE_METHOD);\n\n  try {\n    await openExternalUrl(authUrl.toString());\n    const callbackUrl = await Promise.race([\n      loopback.awaitCallback(),","sourceCodeStart":108,"sourceCodeEnd":144,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/app/src/utils/openrouterOAuth.ts#L108-L144","documentation":"connectOpenRouterViaOAuth first binds a loopback HTTP listener via startLoopbackOauthListener, which only works by invoking a Tauri shell command. Outside the desktop runtime that call resolves null, and the flow aborts immediately with this error telling the user the OAuth path needs the desktop app and to use an API key instead. The same null can occur inside the desktop app during the CEF bootstrap gap where the IPC bridge is not yet wired.","triggerScenarios":"Calling connectOpenRouterViaOAuth from the browser-only Vite dev server (`pnpm dev`), from a Vitest run without stubbing deps.startLoopbackListener, or from the desktop app before `__TAURI_INTERNALS__.invoke` exists — in each case startLoopbackOauthListener returns null and this throws before any browser tab is opened.","commonSituations":"Developer opens the UI in a plain browser via `pnpm dev` and clicks the 'Connect OpenRouter' button; an E2E/unit test exercises the OAuth button without injecting a listener stub; the settings screen renders and fires the flow during early startup before the boot gate confirms IPC.","solutions":["Run the UI inside the Tauri desktop shell — `pnpm dev:app` or the packaged app — where the loopback listener command exists","Gate the OAuth button/flow on isTauri() and offer manual API-key entry in web builds (the error message already steers users there)","In tests, inject `deps.startLoopbackListener` with a stub returning a LoopbackHandle so the flow proceeds deterministically","If it fires inside the desktop app at startup, defer until the boot gate signals the IPC bridge is wired, then retry"],"exampleFix":"// before\nconst key = await connectOpenRouterViaOAuth();\n\n// after\nimport { isTauri } from './tauriCommands/common';\nif (!isTauri()) {\n  showManualApiKeyEntry();\n  return;\n}\nconst key = await connectOpenRouterViaOAuth();","handlingStrategy":"fallback","validationCode":"import { isTauri } from './tauriCommands/common';\n\nif (!isTauri()) {\n  // Web build: skip OAuth entirely, offer the API-key path\n  showManualApiKeyEntry();\n  return;\n}\nconst key = await connectOpenRouterViaOAuth();","typeGuard":null,"tryCatchPattern":"try {\n  const key = await connectOpenRouterViaOAuth();\n} catch (err) {\n  if (err instanceof Error && err.message.includes('requires the desktop app')) {\n    showManualApiKeyEntry(); // fallback path\n  } else {\n    throw err;\n  }\n}","preventionTips":["Gate every OAuth-loopback entry point on isTauri() and always ship the manual API-key alternative","In tests, inject deps.startLoopbackListener instead of relying on the real shell command","Don't auto-start OAuth flows on mount — start them on explicit user action so web builds never hit them incidentally"],"tags":["oauth","tauri","desktop","environment","loopback"],"backgroundTag":"loopback-oauth-listener-unavailable","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}