Summary
In channel host mode (claude --channels plugin:discord@claude-plugins-official, long-running headless process under launchd via a pty.spawn wrapper), Claude Code authenticates with the stored Keychain OAuth session (Claude Code-credentials) even when a valid CLAUDE_CODE_OAUTH_TOKEN (long-lived sk-ant-oat01 from claude setup-token) is present in the process environment.
Per the documented precedence (https://code.claude.com/docs/en/authentication.md), CLAUDE_CODE_OAUTH_TOKEN (tier 5) should win over stored subscription OAuth credentials (tier 6). Interactive mode and headless -p mode both honor this; channel host mode appears not to.
Because the stored access token has a ~8h TTL and is not refreshed in headless/pty contexts (see #50743, #28827), the host silently goes deaf: every command fails with API Error: 401 OAuth access token has expired · Please run /login, while a perfectly valid env token sits unused in its environment.
Environment
- Claude Code 2.1.216 and 2.1.217 (both reproduce), npm global install, macOS (Darwin 25.5.0)
- Host:
claude --channels plugin:discord@claude-plugins-official --no-chrome --mcp-config … --settings …, launched by launchd through python3 -c 'import pty,sys; pty.spawn(sys.argv[1:])' (launchd provides no TTY)
CLAUDE_CODE_OAUTH_TOKEN exported from a 600 env file sourced by the launcher; only auth-related var in the environment (verified via ps eww)
- Stored Keychain entry
Claude Code-credentials present (claudeAiOauth with expiresAt in the past)
Evidence
- Env token valid: direct
curl https://api.anthropic.com/v1/messages with Authorization: Bearer <env token> + anthropic-beta: oauth-2025-04-20 → 200.
- Env token present in the host process:
ps eww <pid> shows CLAUDE_CODE_OAUTH_TOKEN whose value hash equals the env file's token hash.
- Keychain access token expired:
claudeAiOauth.expiresAt ≈ 18h in the past at failure time.
- First message of the day to the host → session transcript records a synthetic assistant turn:
Please run /login · API Error: 401 OAuth access token has expired. Re-authenticate to continue. (turn duration ~2.3s).
- Control experiments (same machine, same expired Keychain entry, same env token, same binary 2.1.217):
- headless:
CLAUDE_CODE_OAUTH_TOKEN=… claude -p "…" → success; Keychain mdat untouched.
- interactive clean-room:
env -i HOME=… PATH=… CLAUDE_CODE_OAUTH_TOKEN=… claude under a fresh pty → success; Keychain mdat untouched (so the reply came from the env token, not a refreshed session).
- Deleting the stored entry (
security delete-generic-password -s "Claude Code-credentials") and restarting the channel host restores service — the host then runs on the env token.
Expected
CLAUDE_CODE_OAUTH_TOKEN takes precedence over stored OAuth credentials in channel host mode, as documented and as observed in -p and plain interactive mode.
Actual
Channel host mode sends the stored (expired) Keychain access token → every turn dies with 401 while a valid env token is available. Failure is silent from the operator's perspective (process, gateway and socket all healthy).
Notes
Summary
In channel host mode (
claude --channels plugin:discord@claude-plugins-official, long-running headless process under launchd via apty.spawnwrapper), Claude Code authenticates with the stored Keychain OAuth session (Claude Code-credentials) even when a validCLAUDE_CODE_OAUTH_TOKEN(long-livedsk-ant-oat01fromclaude setup-token) is present in the process environment.Per the documented precedence (https://code.claude.com/docs/en/authentication.md),
CLAUDE_CODE_OAUTH_TOKEN(tier 5) should win over stored subscription OAuth credentials (tier 6). Interactive mode and headless-pmode both honor this; channel host mode appears not to.Because the stored access token has a ~8h TTL and is not refreshed in headless/pty contexts (see #50743, #28827), the host silently goes deaf: every command fails with
API Error: 401 OAuth access token has expired · Please run /login, while a perfectly valid env token sits unused in its environment.Environment
claude --channels plugin:discord@claude-plugins-official --no-chrome --mcp-config … --settings …, launched by launchd throughpython3 -c 'import pty,sys; pty.spawn(sys.argv[1:])'(launchd provides no TTY)CLAUDE_CODE_OAUTH_TOKENexported from a 600 env file sourced by the launcher; only auth-related var in the environment (verified viaps eww)Claude Code-credentialspresent (claudeAiOauthwithexpiresAtin the past)Evidence
curl https://api.anthropic.com/v1/messageswithAuthorization: Bearer <env token>+anthropic-beta: oauth-2025-04-20→ 200.ps eww <pid>showsCLAUDE_CODE_OAUTH_TOKENwhose value hash equals the env file's token hash.claudeAiOauth.expiresAt≈ 18h in the past at failure time.Please run /login · API Error: 401 OAuth access token has expired. Re-authenticate to continue.(turn duration ~2.3s).CLAUDE_CODE_OAUTH_TOKEN=… claude -p "…"→ success; Keychainmdatuntouched.env -i HOME=… PATH=… CLAUDE_CODE_OAUTH_TOKEN=… claudeunder a fresh pty → success; Keychainmdatuntouched (so the reply came from the env token, not a refreshed session).security delete-generic-password -s "Claude Code-credentials") and restarting the channel host restores service — the host then runs on the env token.Expected
CLAUDE_CODE_OAUTH_TOKENtakes precedence over stored OAuth credentials in channel host mode, as documented and as observed in-pand plain interactive mode.Actual
Channel host mode sends the stored (expired) Keychain access token → every turn dies with 401 while a valid env token is available. Failure is silent from the operator's perspective (process, gateway and socket all healthy).
Notes
claude setup-token's browser OAuth flow also (re)writes the Keychain session entry as a side effect. An operator who "fixes" the 401 by minting a fresh env token thereby re-arms the stored-credential trap with a fresh ~8h access token — which is exactly how this presented as a recurring daily outage.