Skip to content

Commit c270be8

Browse files
fix(installer): seed placeholder bearer so SDK skips local auth check
The Claude Agent SDK's CLI subprocess runs a local auth-source check at startup and immediately emits a result message with is_error: true and result 'Not logged in · Please run /login' — never reaching the gateway — when none of ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, apiKeyHelper, or a '/login'-managed keychain entry is present in its environment. All four proxy / gateway paths in agent-interface.ts were explicitly deleting ANTHROPIC_AUTH_TOKEN on the assumption that the credential proxy handling Authorization upstream was sufficient. For any user without Claude Code set up locally, that assumption left the SDK with source 'none' and the installer failed with 'Not logged in · Please run /login' before a single byte hit the WorkOS gateway. Seed ANTHROPIC_AUTH_TOKEN with a placeholder on the proxy paths so the SDK's local check passes (the credential proxy rewrites Authorization with the real WorkOS token before forwarding), and strip the user's personal ANTHROPIC_API_KEY from sdkEnv so it cannot leak upstream when the proxy is in use. Direct mode is unchanged. Fixes #124
1 parent 802a5db commit c270be8

2 files changed

Lines changed: 148 additions & 9 deletions

File tree

src/lib/agent-interface.spec.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22

33
const { mockQuery, mockConfig } = vi.hoisted(() => ({
44
mockQuery: vi.fn(),
@@ -61,13 +61,22 @@ vi.mock('./token-refresh.js', () => ({
6161

6262
vi.mock('./credential-proxy.js', () => ({
6363
startCredentialProxy: vi.fn(),
64+
startClaimTokenProxy: vi.fn(),
65+
}));
66+
67+
vi.mock('./config-store.js', () => ({
68+
getActiveEnvironment: vi.fn(() => null),
69+
isUnclaimedEnvironment: vi.fn(() => false),
6470
}));
6571

6672
vi.mock('../utils/urls.js', () => ({
6773
getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'),
6874
}));
6975

70-
import { runAgent, AgentErrorType } from './agent-interface.js';
76+
import { runAgent, AgentErrorType, initializeAgent } from './agent-interface.js';
77+
import { startCredentialProxy, startClaimTokenProxy } from './credential-proxy.js';
78+
import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js';
79+
import { hasCredentials, getCredentials } from './credentials.js';
7180
import { InstallerEventEmitter } from './events.js';
7281
import type { InstallerOptions } from '../utils/types.js';
7382

@@ -364,3 +373,114 @@ describe('service unavailability handling', () => {
364373
expect(validateAndFormat).not.toHaveBeenCalled();
365374
});
366375
});
376+
377+
describe('initializeAgent sdkEnv auth', () => {
378+
const PROXY_PLACEHOLDER_TOKEN = 'workos-cli-proxy-placeholder';
379+
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
380+
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
381+
382+
beforeEach(() => {
383+
vi.mocked(startCredentialProxy).mockReset();
384+
vi.mocked(startClaimTokenProxy).mockReset();
385+
vi.mocked(getActiveEnvironment).mockReset().mockReturnValue(null);
386+
vi.mocked(isUnclaimedEnvironment).mockReset().mockReturnValue(false);
387+
vi.mocked(hasCredentials).mockReset().mockReturnValue(false);
388+
vi.mocked(getCredentials).mockReset().mockReturnValue(null);
389+
390+
// Simulate a user shell that has their own Anthropic key sitting in the
391+
// environment. The SDK must NOT forward this to the WorkOS gateway.
392+
process.env.ANTHROPIC_API_KEY = 'sk-ant-user-personal-key';
393+
delete process.env.ANTHROPIC_AUTH_TOKEN;
394+
});
395+
396+
afterEach(() => {
397+
if (originalAnthropicApiKey === undefined) {
398+
delete process.env.ANTHROPIC_API_KEY;
399+
} else {
400+
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
401+
}
402+
if (originalAnthropicAuthToken === undefined) {
403+
delete process.env.ANTHROPIC_AUTH_TOKEN;
404+
} else {
405+
process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken;
406+
}
407+
});
408+
409+
function makeAgentConfigForInit() {
410+
return {
411+
workingDirectory: '/tmp/test',
412+
installDir: '/tmp/test',
413+
};
414+
}
415+
416+
it('seeds placeholder auth token on the credential proxy path', async () => {
417+
vi.mocked(hasCredentials).mockReturnValue(true);
418+
vi.mocked(getCredentials).mockReturnValue({
419+
accessToken: 'real-workos-token',
420+
refreshToken: 'refresh-token',
421+
expiresAt: Date.now() + 60_000,
422+
});
423+
vi.mocked(startCredentialProxy).mockResolvedValue({
424+
port: 12345,
425+
url: 'http://127.0.0.1:12345',
426+
stop: vi.fn(async () => {}),
427+
});
428+
429+
const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false }));
430+
431+
// The SDK runs a local auth-source check at startup and exits with
432+
// "Not logged in" if nothing is present. A placeholder token prevents
433+
// that false-positive; the proxy overwrites Authorization upstream.
434+
expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN);
435+
// User's personal Anthropic key must not leak through to the gateway.
436+
expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined();
437+
expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:12345');
438+
});
439+
440+
it('seeds placeholder auth token on the claim-token proxy path', async () => {
441+
vi.mocked(getActiveEnvironment).mockReturnValue({
442+
apiKey: 'sk_test_x',
443+
clientId: 'client_x',
444+
claimToken: 'claim_xyz',
445+
} as unknown as ReturnType<typeof getActiveEnvironment>);
446+
vi.mocked(isUnclaimedEnvironment).mockReturnValue(true);
447+
vi.mocked(startClaimTokenProxy).mockResolvedValue({
448+
port: 23456,
449+
url: 'http://127.0.0.1:23456',
450+
stop: vi.fn(async () => {}),
451+
});
452+
453+
const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false }));
454+
455+
expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN);
456+
expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined();
457+
expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:23456');
458+
});
459+
460+
it('seeds placeholder auth token in skip-auth mode', async () => {
461+
const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: true, local: false }));
462+
463+
expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN);
464+
expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined();
465+
});
466+
467+
it('seeds placeholder auth token in local mode', async () => {
468+
const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: true }));
469+
470+
expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN);
471+
expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined();
472+
});
473+
474+
it('preserves ANTHROPIC_API_KEY in direct mode', async () => {
475+
const result = await initializeAgent(
476+
makeAgentConfigForInit(),
477+
makeOptions({ direct: true, skipAuth: false, local: false }),
478+
);
479+
480+
// Direct mode talks to api.anthropic.com using the user's own key;
481+
// the placeholder bearer must NOT be set here.
482+
expect(result.sdkEnv.ANTHROPIC_API_KEY).toBe('sk-ant-user-personal-key');
483+
expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
484+
expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBeUndefined();
485+
});
486+
});

src/lib/agent-interface.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,15 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
345345
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 'true',
346346
};
347347

348+
// Placeholder bearer token for the Claude Agent SDK. The SDK's CLI
349+
// subprocess runs a local auth-source check at startup and exits with
350+
// "Not logged in · Please run /login" if no credentials are present in
351+
// its environment — even when a proxy is handling auth upstream. Setting
352+
// this token puts the SDK in custom-backend mode so it skips that check;
353+
// the credential proxy rewrites the Authorization header with the real
354+
// WorkOS token before forwarding upstream.
355+
const PROXY_PLACEHOLDER_TOKEN = 'workos-cli-proxy-placeholder';
356+
348357
if (options.direct) {
349358
// Direct mode: use user's Anthropic API key, skip gateway
350359
if (!process.env.ANTHROPIC_API_KEY) {
@@ -377,7 +386,10 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
377386
});
378387

379388
sdkEnv.ANTHROPIC_BASE_URL = activeProxyHandle.url;
380-
delete sdkEnv.ANTHROPIC_AUTH_TOKEN;
389+
// Prevent the user's personal Anthropic key (if any) from being sent
390+
// to the WorkOS gateway; auth is injected by the claim-token proxy.
391+
delete sdkEnv.ANTHROPIC_API_KEY;
392+
sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN;
381393
authMode = `claim-token-proxy:${activeProxyHandle.url}${gatewayUrl}`;
382394
logInfo(`[agent-interface] Using claim token proxy for unclaimed environment`);
383395
} else if (!options.skipAuth && !options.local) {
@@ -419,8 +431,11 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
419431
sdkEnv.ANTHROPIC_BASE_URL = activeProxyHandle.url;
420432
logInfo(`[agent-interface] Using credential proxy at ${activeProxyHandle.url}`);
421433

422-
// Proxy handles auth, so we don't set ANTHROPIC_AUTH_TOKEN
423-
delete sdkEnv.ANTHROPIC_AUTH_TOKEN;
434+
// Prevent the user's personal Anthropic key (if any) from being
435+
// sent to the WorkOS gateway; the credential proxy rewrites the
436+
// Authorization header with the real WorkOS token.
437+
delete sdkEnv.ANTHROPIC_API_KEY;
438+
sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN;
424439
authMode = `proxy:${activeProxyHandle.url}${gatewayUrl}`;
425440
} else {
426441
// No refresh token OR proxy disabled - fall back to old behavior (5 min limit)
@@ -445,15 +460,19 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
445460
logInfo('Sending access token to gateway (legacy mode)');
446461
}
447462
} else if (options.skipAuth) {
448-
// Skip auth mode - direct to gateway without auth
463+
// Skip auth mode - direct to gateway without auth. Still seed a
464+
// placeholder token so the SDK's local auth-source check passes; the
465+
// gateway itself is expected to accept unauthenticated requests here.
449466
sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl;
450-
delete sdkEnv.ANTHROPIC_AUTH_TOKEN;
467+
delete sdkEnv.ANTHROPIC_API_KEY;
468+
sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN;
451469
authMode = `skip-auth:${gatewayUrl}`;
452470
logInfo('Skipping auth - no token sent to gateway');
453471
} else {
454-
// Local mode without auth
472+
// Local mode without auth - same rationale as skip-auth above.
455473
sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl;
456-
delete sdkEnv.ANTHROPIC_AUTH_TOKEN;
474+
delete sdkEnv.ANTHROPIC_API_KEY;
475+
sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN;
457476
authMode = `local-gateway:${gatewayUrl}`;
458477
logInfo('Local mode - no token sent to gateway');
459478
}

0 commit comments

Comments
 (0)