Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 142 additions & 2 deletions src/lib/agent-interface.spec.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof getActiveEnvironment>);
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();
});
});
42 changes: 33 additions & 9 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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');
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

logInfo('Configured LLM gateway:', sdkEnv.ANTHROPIC_BASE_URL);
Expand Down
Loading