{"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":"Error","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/a221052e0df5b1f7598fceba7329fd1af95d6699/app/src/utils/openrouterOAuth.ts#L108-L144","documentation":"connectOpenRouterViaOAuth() runs a PKCE OAuth flow that needs a loopback HTTP listener (startLoopbackOauthListener on OPENROUTER_LOOPBACK_PORT) to receive OpenRouter's callback. Only the Tauri desktop shell can bind that listener; when startLoopbackListener resolves falsy the flow has no way to receive the auth code, so the function aborts immediately and points you at API-key auth instead.","triggerScenarios":"Calling connectOpenRouterViaOAuth() with default deps outside the desktop shell: UI served by `pnpm dev` (Vite-only) and opened in a plain browser tab, a Vitest run importing the real module, or any context where the loopback listener returns null/undefined. Can also fire inside the desktop app if the listener genuinely fails to start.","commonSituations":"Developer runs `pnpm dev` instead of `pnpm dev:app` and clicks Connect OpenRouter in settings; a component test exercises the OAuth path without injecting deps.startLoopbackListener; the loopback port is blocked or already bound by another process so even the desktop listener fails.","solutions":["Run the UI inside the Tauri desktop shell (`pnpm dev:app` or the packaged app) so the loopback listener can bind.","If you are in a browser context by design, use the API-key entry path instead — OAuth is desktop-only by construction.","In tests, inject a stub listener: connectOpenRouterViaOAuth({ startLoopbackListener: async () => ({ redirectUri: 'http://localhost:1/cb', cancel: async () => {} }) }).","If it throws inside the desktop app, check OPENROUTER_LOOPBACK_PORT availability (firewall rules, another instance holding the port)."],"exampleFix":"// before\nconst apiKey = await connectOpenRouterViaOAuth();\n// after\nimport { isTauri } from '@/utils/tauriCommands/common';\nif (!isTauri()) {\n  showApiKeyEntry(); // OAuth needs the desktop loopback listener\n} else {\n  const apiKey = await connectOpenRouterViaOAuth();\n}","handlingStrategy":"validation","validationCode":"import { isTauri } from '@/utils/tauriCommands/common';\n\n// Only offer OAuth where the loopback listener can exist\nif (!isTauri()) {\n  // render API-key entry instead of the OAuth button\n}","typeGuard":null,"tryCatchPattern":"try {\n  const key = await connectOpenRouterViaOAuth({ signal });\n} catch (err) {\n  if (err instanceof Error && err.message.includes('requires the desktop app')) {\n    showApiKeyEntry(); // graceful fallback path\n  } else {\n    throw err;\n  }\n}","preventionTips":["Gate the Connect-with-OAuth button on isTauri() so browser users never see a dead action.","Inject deps.startLoopbackListener in tests instead of relying on the environment.","Always keep an API-key path available wherever OAuth is offered."],"tags":["oauth","openrouter","loopback","desktop","environment"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}