tinyhumansai/openhuman · error

OpenRouter OAuth requires the desktop app. Use an API key…

Error message

OpenRouter OAuth requires the desktop app. Use an API key instead.

What it means

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.

Solutions

  1. Run the UI inside the Tauri desktop shell — `pnpm dev:app` or the packaged app — where the loopback listener command exists
  2. Gate the OAuth button/flow on isTauri() and offer manual API-key entry in web builds (the error message already steers users there)
  3. In tests, inject `deps.startLoopbackListener` with a stub returning a LoopbackHandle so the flow proceeds deterministically
  4. If it fires inside the desktop app at startup, defer until the boot gate signals the IPC bridge is wired, then retry

Example fix

// before
const key = await connectOpenRouterViaOAuth();

// after
import { isTauri } from './tauriCommands/common';
if (!isTauri()) {
  showManualApiKeyEntry();
  return;
}
const key = await connectOpenRouterViaOAuth();
Defensive patterns

Strategy: fallback

Validate before calling

import { isTauri } from './tauriCommands/common';

if (!isTauri()) {
  // Web build: skip OAuth entirely, offer the API-key path
  showManualApiKeyEntry();
  return;
}
const key = await connectOpenRouterViaOAuth();

Try / catch

try {
  const key = await connectOpenRouterViaOAuth();
} catch (err) {
  if (err instanceof Error && err.message.includes('requires the desktop app')) {
    showManualApiKeyEntry(); // fallback path
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17). Data as JSON: /api/errors/e1b4c4a1566926d2. Report an issue: GitHub.

Appendix: source

Thrown at app/src/utils/openrouterOAuth.ts:126

  // Preserve the port the loopback listener actually bound to (carried in
  // redirectUri): when the requested port is busy, the Tauri command falls back
  // to an OS-assigned ephemeral port, so hardcoding OPENROUTER_LOOPBACK_PORT here
  // sent OpenRouter a callback_url pointing at the wrong port. The PKCE
  // callback_url is per-request, so the dynamic port is valid (this matches the
  // sibling OAuthProviderButton flow, which trusts the bound port).
  parsed.hostname = 'localhost';
  return parsed.toString();
}

export async function connectOpenRouterViaOAuth(deps: OpenRouterOAuthDeps = {}): Promise<string> {
  const startLoopbackListener = deps.startLoopbackListener ?? startLoopbackOauthListener;
  const openExternalUrl = deps.openExternalUrl ?? openUrl;
  const fetchImpl = deps.fetchImpl ?? fetch;
  const signal = deps.signal;

  const loopback = await startLoopbackListener({ port: OPENROUTER_LOOPBACK_PORT });
  if (!loopback) {
    throw new Error('OpenRouter OAuth requires the desktop app. Use an API key instead.');
  }

  if (signal?.aborted) {
    await loopback.cancel();
    throw new Error('OpenRouter OAuth was cancelled.');
  }

  const verifier = randomVerifier();
  const challenge = await createCodeChallenge(verifier);
  const authUrl = new URL(OPENROUTER_AUTH_URL);
  authUrl.searchParams.set('callback_url', toOpenRouterCallbackUrl(loopback.redirectUri));
  authUrl.searchParams.set('code_challenge', challenge);
  authUrl.searchParams.set('code_challenge_method', PKCE_METHOD);

  try {
    await openExternalUrl(authUrl.toString());
    const callbackUrl = await Promise.race([
      loopback.awaitCallback(),

View on GitHub (pinned to 7491200858)