Skip to content

loginAnthropic ignores options.signal and leaks its fixed-port (53692) OAuth callback server on abort #5649

@Wirasm

Description

@Wirasm

Summary

loginAnthropic (packages/ai/src/utils/oauth/anthropic.ts) never subscribes to options.signal, and its OAuth callback server binds a fixed port (53692). When a host application cancels an in-flight login via the signal it passed to provider.login(callbacks), the callback server keeps listening until the login promise settles some other way — which, for an abandoned login, is never. From that point on, every subsequent Anthropic login in the same process (and any other process on the host) fails with EADDRINUSE on 53692 until restart.

Observed on @earendil-works/pi-ai 0.79.1, embedded in a long-running server (Archon) that drives login() headlessly via onAuth + onManualCodeInput.

Details (0.79.1, dist/utils/oauth/anthropic.js)

  1. anthropicOAuthProvider.login(callbacks) forwards onAuth / onPrompt / onProgress / onManualCodeInput to loginAnthropic, but drops callbacks.signal entirely.
  2. loginAnthropic itself never reads options.signal either (compare loginOpenAICodexDeviceCode, which does pass it through to its fetches).
  3. The callback server is only closed in the finally after waitForCode() settles. waitForCode() settles only when (a) the browser hits the callback, or (b) cancelWait() runs because the onManualCodeInput() promise settled. An abort signal triggers neither.
  4. CALLBACK_PORT = 53692 is fixed, so the leaked listener blocks all future Anthropic logins process- and host-wide, not just a retry of the same flow.

For a long-lived multi-user host this is severe: one user abandoning a login bricks Anthropic subscription login for everyone until the process restarts.

Workaround we ship today: since rejecting the onManualCodeInput() promise drives the flow through its error path into finally { server.close() }, our bridge rejects that promise on cancel. It works, but it leans on an implementation detail of the manual-input race rather than the documented signal contract.

Suggested fixes (either helps, both is best)

  1. Honor options.signal in loginAnthropic (and pass it through from anthropicOAuthProvider.login): on abort, cancelWait() + close the server and reject with a "Login cancelled" error — same semantics fetchWithLoginCancellation already gives the Codex flow.
  2. Bind an ephemeral port (listen(0)) and build the redirect URI from the actual bound port, so a leaked or concurrent listener can't wedge future logins. (If the fixed port is part of the registered OAuth app's redirect URI, then fix 1 alone still resolves the leak.)

loginOpenAICodex's browser flow (fixed port 1455) has the same shape — signal is accepted in its options type but the callback server is never closed on abort — so a shared fix in the callback-server helper would cover both.

Repro sketch

import { anthropicOAuthProvider } from "@earendil-works/pi-ai/oauth";

const ac = new AbortController();
const login = anthropicOAuthProvider.login({
  onAuth: ({ url }) => console.log("authorize at", url),
  onManualCodeInput: () => new Promise(() => {}), // user never pastes a code
  onPrompt: async () => new Promise(() => {}),
  onSelect: async (p) => p.options[0]?.id,
  onDeviceCode: () => {},
  signal: ac.signal,
});
setTimeout(() => ac.abort(), 1000); // no effect: port 53692 stays LISTEN forever

Then any second login() in any process rejects with listen EADDRINUSE: address already in use 127.0.0.1:53692.

Happy to provide more detail. (Found while integrating pi's OAuth flows into Archon's subscription-login bridge; downstream report: coleam00/Archon#1963.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions