Cached auth token from exchange_user_api_key (1h TTL) is never refreshed on the run path

Where does the bug appear (feature/product)?

Cursor SDK

Describe the bug

A long-lived Node service using @cursor/sdk local agents experienced windows (hours long) where every agent.send()run.wait() failed in under 3 seconds with {status: "error"}, zero tokens consumed, zero onDelta events, and result: undefined — on resumed sessions and brand-new Agent.create() sessions alike. The real error is never surfaced to the caller: RunResult has no error field. It is only written to the local run store (runs.ndjson, error field), where all 58 failed runs carry the same message:

“Authentication error. If you are logged in, try logging out and back in.”

We initially suspected the cached session token from POST /auth/exchange_user_api_key (a JWT with exp − iat = 3600s, cached in module scope by the SDK) expiring without refresh. Extensive follow-up testing ruled out every client-side trigger we could construct, all against the live API with the same key:

  • Probes at t+63/67/72/75/90 minutes past the token TTL all passed — in minimal config and in our full production config (large git workspace, settingSources: ["project"] with ~330 rules, thinking: medium params, per-send HTTP MCP servers), both idle and with periodic activity.
  • A process SIGSTOP-frozen for 30 minutes and for 8 hours (timers frozen, upstream connections torn down server-side, wall-clock jump on resume) recovered fine on wake.
  • Machine sleep was ruled out via the power log (no system sleep occurred on the failure days).
  • Injecting 401 responses into exchange_user_api_key reproduces the failure signature during the injected outage, but the SDK recovers immediately once the endpoint returns healthy responses — even when the first-ever exchange of the process fails. No persistent client-side poisoning was reproducible.

Conclusion: during the failure windows, the API was actually rejecting this key’s runs. Our dashboard shows we are under quota, status.cursor.com shows no incident for those dates, and since we run with privacy mode our requests likely aren’t retained server-side for lookup. So our questions are: (1) under what conditions does the run path return this in-band “Authentication error…” rejection (key state? per-key throttling? auth-service transients?), and (2) what should we capture client-side on the next occurrence to make this diagnosable — is there a debug flag or verbose logging mode for the SDK’s transport/auth layer, and is the raw HTTP status/error body of the run rejection accessible anywhere client-side?

Independent of the rejections’ cause, three SDK defects verified during the investigation:

  1. run.wait() swallows the error. RunResult carries no error code/message; the detail exists only in the local run store. Confirmed against the type definitions (run.d.ts) and observed behavior.
  2. In-band auth errors bypass recovery. The re-auth interceptor ("Auth expired, re-authenticating") and the client-rebuild wrapper only trigger on a ConnectError with Code.Unauthenticated. Auth failures delivered as in-band stream error events (ours had no [code] prefix, indicating the status-message path) trigger neither.
  3. A failed key exchange during Agent.create crashes bare processes via an unhandled promise rejection.

Steps to reproduce

The rejection windows themselves are not client-reproducible (see the elimination list above). The error-swallowing defect (#1) reproduces deterministically with fault injection:

// Patch fetch BEFORE importing the SDK so /auth/exchange_user_api_key returns 401:
const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
  const url = String(typeof input === "string" ? input : input?.url);
  if (url.includes("exchange_user_api_key")) {
    return new Response(
      JSON.stringify({ error: { message: "Authentication error If you are logged in, try logging out and back in." } }),
      { status: 401, headers: { "Content-Type": "application/json" } },
    );
  }
  return realFetch(input, init);
};

const { Agent, JsonlLocalAgentStore } = await import("@cursor/sdk");
const store = new JsonlLocalAgentStore("./store");
const agent = await Agent.create({
  apiKey: process.env.CURSOR_API_KEY,
  model: { id: "claude-sonnet-4-6" },
  mode: "agent",
  local: { cwd: "./empty-dir", store, settingSources: [] },
});
const run = await agent.send("Reply with exactly: ok");
const result = await run.wait();
console.log(result);
// -> { status: "error", result: undefined }  — NO error detail
// ./store/runs.ndjson meanwhile holds the real message in the run's `error` field.

Defect #3 reproduces with the same injection in a process that has no unhandledRejection handler: the process crashes.

Expected behavior

  • RunResult exposes the error code/message that is already written to the run store.
  • Auth rejections on the streaming run path trigger the same re-auth/rebuild recovery that unary calls get.
  • A failed exchange rejects the Agent.create/send promise instead of escaping as an unhandled rejection.
  • Guidance on diagnosing this class of rejection under privacy mode, where server-side request lookup isn’t available — ideally an SDK debug/verbose mode that logs the transport-level status and error body of rejected runs.

OS

macOS 26.5.2 (25F84), Apple Silicon. The same service also runs in node:22-alpine Linux containers.

Version info

  • @cursor/sdk: failures observed on 1.0.19 and 1.0.22; investigation performed on 1.0.22; since upgraded to 1.0.23
  • Node.js v22.22.3
  • Local agents (mode: "agent", local: {...}), auth via dashboard user API key, privacy mode enabled

If AI issue, what model

claude-sonnet-4-6 (explicit model id). Not model-related — failures occur before any model tokens are consumed.

Additional info

  • Failure windows (UTC): 2026-07-02 ~15:40–19:48; 2026-07-03 ~07:48–08:03 and ~13:34.
  • All failed runs have requestId set, durationMs 600–3000ms, empty usage (we can share ~60 requestIds if useful despite privacy mode).
  • Heavy usage (~1.7M input tokens in 20 minutes) immediately preceded the 07-03 morning window, in case per-key throttling is relevant — though the dashboard shows us under quota.
  • Failure phenomenology: within a window, a first attempt fails in ~1.6–3s and immediate retries in ~0.6–0.9s; new Agent.create() sessions fail identically to resumed ones; warm-up-style sends after a process restart succeeded whenever they happened to fall outside a window.
  • Related: the run.wait() error-swallowing gap was previously acknowledged by staff in thread 164248 (named models failing silently when over limits).

Does this stop you from using Cursor

Sometimes - I can sometimes use Cursor

Hi @yaakov_fg Thank you for the post! I’ve raised a bug report on this but I want to do a bit more replication steps myself to help pinpoint the root cause and speed up progress on the issue resolution.