Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
124 changes: 122 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 } 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,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',
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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<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('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();
});
});
33 changes: 26 additions & 7 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 @@ -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');
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down
Loading