diff --git a/src/lib/agent-interface.spec.ts b/src/lib/agent-interface.spec.ts index c4f1486f..489dbb1c 100644 --- a/src/lib/agent-interface.spec.ts +++ b/src/lib/agent-interface.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockQuery, mockConfig } = vi.hoisted(() => ({ mockQuery: vi.fn(), @@ -61,13 +61,22 @@ vi.mock('./token-refresh.js', () => ({ vi.mock('./credential-proxy.js', () => ({ startCredentialProxy: vi.fn(), + startClaimTokenProxy: vi.fn(), +})); + +vi.mock('./config-store.js', () => ({ + getActiveEnvironment: vi.fn(() => null), + isUnclaimedEnvironment: vi.fn(() => false), })); vi.mock('../utils/urls.js', () => ({ getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'), })); -import { runAgent, AgentErrorType } from './agent-interface.js'; +import { runAgent, AgentErrorType, initializeAgent, type AgentConfig } from './agent-interface.js'; +import { startCredentialProxy, startClaimTokenProxy } from './credential-proxy.js'; +import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; +import { hasCredentials, getCredentials } from './credentials.js'; import { InstallerEventEmitter } from './events.js'; import type { InstallerOptions } from '../utils/types.js'; @@ -364,3 +373,134 @@ describe('service unavailability handling', () => { expect(validateAndFormat).not.toHaveBeenCalled(); }); }); + +describe('initializeAgent sdkEnv auth', () => { + const PROXY_PLACEHOLDER_TOKEN = 'workos-cli-proxy-placeholder'; + const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; + const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; + + beforeEach(() => { + vi.mocked(startCredentialProxy).mockReset(); + vi.mocked(startClaimTokenProxy).mockReset(); + vi.mocked(getActiveEnvironment).mockReset().mockReturnValue(null); + vi.mocked(isUnclaimedEnvironment).mockReset().mockReturnValue(false); + vi.mocked(hasCredentials).mockReset().mockReturnValue(false); + vi.mocked(getCredentials).mockReset().mockReturnValue(null); + + // Simulate a user shell that has their own Anthropic key sitting in the + // environment. The SDK must NOT forward this to the WorkOS gateway. + process.env.ANTHROPIC_API_KEY = 'sk-ant-user-personal-key'; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + afterEach(() => { + if (originalAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey; + } + if (originalAnthropicAuthToken === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicAuthToken; + } + }); + + function makeAgentConfigForInit(): AgentConfig { + return { + workingDirectory: '/tmp/test', + workOSApiKey: 'sk_test_x', + workOSApiHost: 'https://api.workos.com', + }; + } + + it('seeds placeholder auth token on the credential proxy path', async () => { + vi.mocked(hasCredentials).mockReturnValue(true); + vi.mocked(getCredentials).mockReturnValue({ + accessToken: 'real-workos-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000, + userId: 'user_x', + }); + vi.mocked(startCredentialProxy).mockResolvedValue({ + port: 12345, + url: 'http://127.0.0.1:12345', + stop: vi.fn(async () => {}), + }); + + const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false })); + + // The SDK runs a local auth-source check at startup and exits with + // "Not logged in" if nothing is present. A placeholder token prevents + // that false-positive; the proxy overwrites Authorization upstream. + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN); + // User's personal Anthropic key must not leak through to the gateway. + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:12345'); + }); + + it('seeds placeholder auth token on the claim-token proxy path', async () => { + vi.mocked(getActiveEnvironment).mockReturnValue({ + apiKey: 'sk_test_x', + clientId: 'client_x', + claimToken: 'claim_xyz', + } as unknown as ReturnType); + vi.mocked(isUnclaimedEnvironment).mockReturnValue(true); + vi.mocked(startClaimTokenProxy).mockResolvedValue({ + port: 23456, + url: 'http://127.0.0.1:23456', + stop: vi.fn(async () => {}), + }); + + const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false })); + + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN); + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:23456'); + }); + + it('seeds placeholder auth token in skip-auth mode', async () => { + const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: true, local: false })); + + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN); + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('seeds placeholder auth token in local mode', async () => { + const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: true })); + + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_PLACEHOLDER_TOKEN); + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('strips ANTHROPIC_API_KEY on legacy fallback path (no refresh token)', async () => { + vi.mocked(hasCredentials).mockReturnValue(true); + // No refreshToken - triggers the legacy fallback branch in initializeAgent. + vi.mocked(getCredentials).mockReturnValue({ + accessToken: 'real-workos-token', + expiresAt: Date.now() + 60_000, + userId: 'user_x', + }); + + const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false })); + + // Legacy path sends the real WorkOS access token as the bearer; the + // user's personal Anthropic key must not tag along as an x-api-key + // header to the WorkOS gateway. + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe('real-workos-token'); + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('preserves ANTHROPIC_API_KEY in direct mode', async () => { + const result = await initializeAgent( + makeAgentConfigForInit(), + makeOptions({ direct: true, skipAuth: false, local: false }), + ); + + // Direct mode talks to api.anthropic.com using the user's own key; + // the placeholder bearer must NOT be set here. + expect(result.sdkEnv.ANTHROPIC_API_KEY).toBe('sk-ant-user-personal-key'); + expect(result.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(result.sdkEnv.ANTHROPIC_BASE_URL).toBeUndefined(); + }); +}); diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 59dae0f1..090a2538 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -345,6 +345,15 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 'true', }; + // Placeholder bearer token for the Claude Agent SDK. The SDK's CLI + // subprocess runs a local auth-source check at startup and exits with + // "Not logged in ยท Please run /login" if no credentials are present in + // its environment โ€” even when a proxy is handling auth upstream. Setting + // this token puts the SDK in custom-backend mode so it skips that check; + // the credential proxy rewrites the Authorization header with the real + // WorkOS token before forwarding upstream. + const PROXY_PLACEHOLDER_TOKEN = 'workos-cli-proxy-placeholder'; + if (options.direct) { // Direct mode: use user's Anthropic API key, skip gateway if (!process.env.ANTHROPIC_API_KEY) { @@ -377,7 +386,10 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt }); sdkEnv.ANTHROPIC_BASE_URL = activeProxyHandle.url; - delete sdkEnv.ANTHROPIC_AUTH_TOKEN; + // Prevent the user's personal Anthropic key (if any) from being sent + // to the WorkOS gateway; auth is injected by the claim-token proxy. + delete sdkEnv.ANTHROPIC_API_KEY; + sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN; authMode = `claim-token-proxy:${activeProxyHandle.url}โ†’${gatewayUrl}`; logInfo(`[agent-interface] Using claim token proxy for unclaimed environment`); } else if (!options.skipAuth && !options.local) { @@ -419,8 +431,11 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt sdkEnv.ANTHROPIC_BASE_URL = activeProxyHandle.url; logInfo(`[agent-interface] Using credential proxy at ${activeProxyHandle.url}`); - // Proxy handles auth, so we don't set ANTHROPIC_AUTH_TOKEN - delete sdkEnv.ANTHROPIC_AUTH_TOKEN; + // Prevent the user's personal Anthropic key (if any) from being + // sent to the WorkOS gateway; the credential proxy rewrites the + // Authorization header with the real WorkOS token. + delete sdkEnv.ANTHROPIC_API_KEY; + sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN; authMode = `proxy:${activeProxyHandle.url}โ†’${gatewayUrl}`; } else { // No refresh token OR proxy disabled - fall back to old behavior (5 min limit) @@ -440,22 +455,31 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt } sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl; + // Prevent the user's personal Anthropic key (if any) from being + // forwarded to the WorkOS gateway as an x-api-key header alongside + // the WorkOS access token we set below. + delete sdkEnv.ANTHROPIC_API_KEY; sdkEnv.ANTHROPIC_AUTH_TOKEN = creds.accessToken; authMode = options.local ? `local-gateway:${gatewayUrl}` : `workos-gateway:${gatewayUrl}`; logInfo('Sending access token to gateway (legacy mode)'); } } else if (options.skipAuth) { - // Skip auth mode - direct to gateway without auth + // Skip auth mode - direct to gateway without a real token. The SDK's + // local auth-source check would otherwise fail with "Not logged in", + // so seed a placeholder bearer; the gateway is expected to accept + // unauthenticated requests here and ignore the placeholder value. sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl; - delete sdkEnv.ANTHROPIC_AUTH_TOKEN; + delete sdkEnv.ANTHROPIC_API_KEY; + sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN; authMode = `skip-auth:${gatewayUrl}`; - logInfo('Skipping auth - no token sent to gateway'); + logInfo('Skipping auth - placeholder bearer sent to gateway'); } else { - // Local mode without auth + // Local mode without auth - same rationale as skip-auth above. sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl; - delete sdkEnv.ANTHROPIC_AUTH_TOKEN; + delete sdkEnv.ANTHROPIC_API_KEY; + sdkEnv.ANTHROPIC_AUTH_TOKEN = PROXY_PLACEHOLDER_TOKEN; authMode = `local-gateway:${gatewayUrl}`; - logInfo('Local mode - no token sent to gateway'); + logInfo('Local mode - placeholder bearer sent to gateway'); } logInfo('Configured LLM gateway:', sdkEnv.ANTHROPIC_BASE_URL);