Skip to content

[bug] Remote session silently swallows messages on claude provider 401 (credentials.json OAuth) — older CLI surfaced it #186

Description

@danljungstrom

Summary

On a self-hosted remote session, when the claude provider returns 401 (expired/unrefreshed OAuth token), the current CLI (0.2.8-dev.x) silently swallows the user's message — the UI accepts it, it's dropped, the turn finalizes as completed, and no error surfaces. An older version (0.2.1) surfaced it: Failed to authenticate. API Error: 401 The socket connection was closed unexpectedly. For more information, pass verbose: true in the second argument to fetch().

Two concurrent sessions on the same machine (sharing ~/.claude/.credentials.json): the 0.2.1 session showed the 401; the new-version session went silent. claude /login (re-mint token) fixed both.

Root cause (traced)

The message is delivered to the agent (deliveredMaterializedMessage:true); it dies inside the claude Agent-SDK turn. The 401 arrives as an in-band system/api_error event (SDK retries internally, "attempt 1/11") rather than a thrown error. Two independent gates then fail open, so nothing surfaces:

  1. Key mismatch in the classifier. claudeRemoteAgentSdk.tsclassifyClaudeConnectedServiceRuntimeAuthFailure (isClaudeRuntimeAuthFailureEvidence) only inspects 401 under keys api_error_status / status / statusCode / status_code. The SDK's api_error system event carries the code under key error_status (plus a short error string like "Connection error."). Neither matches → classifier returns nullonRuntimeAuthFailureEvent never fires.
  2. Connected-service gate. Even if it fired, surfaceClaudeConnectedServiceRuntimeAuthFailure returns early when there is no connected-service selection. Installs that auth via shared ~/.claude/.credentials.json OAuth have connectedServiceSelection: null, so this surface path is also dead.

Net: the turn finalizes completed, no sendSessionEvent error is emitted, the message is lost.

Why 0.2.1 surfaced it

0.2.1's SDK path threw on the 401 (isClaudeAgentSdkAuthenticationError matched the thrown message: API Error: 401, Failed to authenticate, OAuth token has expired); the throw propagated to the launcher catch → exit-code-1 branch → sendSessionEvent({type:'message', ...}) via the UI error formatter → surfaced. The move to in-band structured classification (+ internal retry + connected-service gate) is what introduced the silent swallow.

Environment

Self-hosted, remote sessions, claude auth via ~/.claude/.credentials.json OAuth (no connected-service injection). New-version session affected; 0.2.1 session on the same host/credentials surfaced correctly.

Repro

  1. Remote session on the current CLI, claude authed via credentials.json OAuth (no connected-service selection).
  2. Make claude return 401 (expire the OAuth token with a failed refresh, or invalidate it).
  3. Send a message.
  4. Observe: message accepted by UI, dropped, turn ends completed, no error shown.

Expected

Provider auth failures (401) surface to the user (as 0.2.1 did) so they know to re-auth — including on credentials.json-OAuth installs with no connected-service selection.

Suggested fixes

  1. Add error_status to the key set in isClaudeRuntimeAuthFailureEvidence (the SDK api_error event uses it).
  2. Surface provider auth failures regardless of connected-service selection — surfaceClaudeConnectedServiceRuntimeAuthFailure should not early-return for credentials.json-OAuth sessions; route to surfacePrimarySessionRuntimeIssue so it's not a silent no-op.

Evidence (logs, anonymized)

  • Client 401: [ERROR] API error (attempt 1/11): 401 401 The socket connection was closed unexpectedly. For more information, pass verbose: true ... (and an earlier {"type":"authentication_error","message":"Invalid authentication credentials"}).
  • Runtime: [claudeRemoteAgentSdk] Claude runtime auth diagnostic ... connectedServiceSelection:null; inbound system event with keys attempt/error/error_status/max_retries/retry_delay_ms; [pendingQueue] ... deliveredMaterializedMessage:true; [pendingQueue] turn-end drain trigger ... terminalStatus:"completed"task_complete; no error event emitted. Two consecutive turns dropped this way before a manual claude /login recovered it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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