From c270be8cb2daea91e5aea3cb4cf55f14a48137d7 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" Date: Thu, 23 Apr 2026 17:35:53 +0000 Subject: [PATCH 1/3] fix(installer): seed placeholder bearer so SDK skips local auth check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/agent-interface.spec.ts | 124 +++++++++++++++++++++++++++++++- src/lib/agent-interface.ts | 33 +++++++-- 2 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/lib/agent-interface.spec.ts b/src/lib/agent-interface.spec.ts index c4f1486f..477c8113 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 } 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,114 @@ 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() { + return { + workingDirectory: '/tmp/test', + installDir: '/tmp/test', + }; + } + + 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, + }); + 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('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..689df305 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) @@ -445,15 +460,19 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt 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 auth. Still seed a + // placeholder token so the SDK's local auth-source check passes; the + // gateway itself is expected to accept unauthenticated requests here. 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'); } 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'); } From 553ef3ea7290c42ad070d30600bba0df9f9df28b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:49:49 +0000 Subject: [PATCH 2/3] fix(installer): strip ANTHROPIC_API_KEY on legacy fallback path Addresses Greptile P1: the legacy fallback path (no refresh token or INSTALLER_DISABLE_PROXY=1) still leaked the user's personal ANTHROPIC_API_KEY to the WorkOS gateway as an x-api-key header alongside the WorkOS access token. Every other non-direct path already deletes it; this brings the legacy branch in line. Also clarifies the skip-auth/local log messages to reflect that a placeholder bearer is now forwarded to the gateway (the SDK's local auth-source check would otherwise fail with 'Not logged in'). Co-Authored-By: nick.nisi@workos.com --- src/lib/agent-interface.spec.ts | 17 +++++++++++++++++ src/lib/agent-interface.ts | 15 ++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib/agent-interface.spec.ts b/src/lib/agent-interface.spec.ts index 477c8113..6395b8c9 100644 --- a/src/lib/agent-interface.spec.ts +++ b/src/lib/agent-interface.spec.ts @@ -471,6 +471,23 @@ describe('initializeAgent sdkEnv auth', () => { 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); + vi.mocked(getCredentials).mockReturnValue({ + accessToken: 'real-workos-token', + refreshToken: null, + expiresAt: Date.now() + 60_000, + }); + + 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(), diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 689df305..090a2538 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -455,26 +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. Still seed a - // placeholder token so the SDK's local auth-source check passes; the - // gateway itself is expected to accept unauthenticated requests here. + // 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_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 - same rationale as skip-auth above. sdkEnv.ANTHROPIC_BASE_URL = gatewayUrl; 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); From 04831eb892503bf54cf590cfa22750174f22a621 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:55:58 +0000 Subject: [PATCH 3/3] test(installer): use typed AgentConfig helper in agent-interface spec Spec files are excluded from pnpm typecheck, which masked a latent type mismatch in makeAgentConfigForInit (returned installDir instead of the required workOSApiKey / workOSApiHost fields). Ann otate the helper return type and align fields with AgentConfig so it would catch regressions under a stricter typecheck config. Co-Authored-By: nick.nisi@workos.com --- src/lib/agent-interface.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/agent-interface.spec.ts b/src/lib/agent-interface.spec.ts index 6395b8c9..489dbb1c 100644 --- a/src/lib/agent-interface.spec.ts +++ b/src/lib/agent-interface.spec.ts @@ -73,7 +73,7 @@ vi.mock('../utils/urls.js', () => ({ getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'), })); -import { runAgent, AgentErrorType, initializeAgent } 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'; @@ -406,10 +406,11 @@ describe('initializeAgent sdkEnv auth', () => { } }); - function makeAgentConfigForInit() { + function makeAgentConfigForInit(): AgentConfig { return { workingDirectory: '/tmp/test', - installDir: '/tmp/test', + workOSApiKey: 'sk_test_x', + workOSApiHost: 'https://api.workos.com', }; } @@ -419,6 +420,7 @@ describe('initializeAgent sdkEnv auth', () => { accessToken: 'real-workos-token', refreshToken: 'refresh-token', expiresAt: Date.now() + 60_000, + userId: 'user_x', }); vi.mocked(startCredentialProxy).mockResolvedValue({ port: 12345, @@ -473,10 +475,11 @@ describe('initializeAgent sdkEnv auth', () => { 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', - refreshToken: null, expiresAt: Date.now() + 60_000, + userId: 'user_x', }); const result = await initializeAgent(makeAgentConfigForInit(), makeOptions({ skipAuth: false, local: false }));