diff --git a/.gitignore b/.gitignore index aed9ce80..6e683e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,5 @@ local/* MCP directories /.serena/ /.agents/skills/bmad* + +.codemie \ No newline at end of file diff --git a/README.md b/README.md index c147e433..1444dd62 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ CodeMie CLI is the all-in-one AI coding assistant for developers. - šŸ” **Enterprise Ready** - SSO and JWT authentication, audit logging, and role-based access. - ⚔ **Productivity Boost** - Code review, refactoring, test generation, and bug fixing. - šŸŽÆ **Profile Management** - Manage work, personal, and team configurations separately. +- 🧩 **CodeMie Assistants in Claude** - Connect your available CodeMie assistants as Claude subagents or skills. - šŸ“Š **Usage Analytics** - Track and analyze AI usage across all agents with detailed insights. - šŸ”§ **CI/CD Workflows** - Automated code review, fixes, and feature implementation. @@ -216,6 +217,33 @@ Auto-updates are automatically disabled to maintain version control. CodeMie not For more detailed information on the available agents, see the [Agents Documentation](docs/AGENTS.md). +### CodeMie Assistants as Claude Skills or Subagents + +CodeMie can connect assistants available in your CodeMie account directly into Claude Code. Register them as Claude subagents and call them with `@slug`, or register them as Claude skills and invoke them with `/slug`. + +```bash +# Pick assistants from your CodeMie account and choose how to register them +codemie setup assistants +``` + +During setup, choose: +- **Claude Subagents** - register selected assistants as `@slug` +- **Claude Skills** - register selected assistants as `/slug` +- **Manual Configuration** - choose skill or subagent per assistant + +After registration, use them from Claude Code: + +```text +@api-reviewer Review this authentication flow +/release-checklist prepare a release checklist for this branch +``` + +You can also message a registered assistant directly through CodeMie: + +```bash +codemie assistants chat "assistant-id" "Review this API design" +``` + ### Claude Code Built-in Commands When using Claude Code (`codemie-claude`), you get access to powerful built-in commands for project documentation: diff --git a/package-lock.json b/package-lock.json index f76e05ee..f17a6d56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2288,6 +2288,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz", "integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -3650,6 +3651,7 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3759,6 +3761,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -4077,6 +4080,7 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -4126,6 +4130,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4901,6 +4906,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -5367,6 +5373,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7714,6 +7721,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.0.tgz", "integrity": "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -9053,6 +9061,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9209,6 +9218,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9290,6 +9300,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9383,6 +9394,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9396,6 +9408,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -9687,6 +9700,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/agents/core/BaseAgentAdapter.ts b/src/agents/core/BaseAgentAdapter.ts index e53c9147..2d189275 100644 --- a/src/agents/core/BaseAgentAdapter.ts +++ b/src/agents/core/BaseAgentAdapter.ts @@ -801,11 +801,19 @@ export abstract class BaseAgentAdapter implements AgentAdapter { if (!providerName) return false; const provider = ProviderRegistry.getProvider(providerName); + + // Providers with no authentication requirement never route through the proxy. + // This also guards against stale CODEMIE_AUTH_METHOD='jwt' values persisting + // in process.env from a previous JWT-authenticated session (written by + // Object.assign(process.env, env) at the end of run()). + if (provider?.authType === 'none') return false; + const isSSOProvider = provider?.authType === 'sso'; const isJWTAuth = env.CODEMIE_AUTH_METHOD === 'jwt'; const isProxyEnabled = this.metadata.ssoConfig?.enabled ?? false; - // Proxy needed for SSO cookie injection OR JWT bearer token injection + // Proxy is only for model API authentication/forwarding. Analytics sync can + // be configured independently and must not force native providers through it. return (isSSOProvider || isJWTAuth) && isProxyEnabled; } @@ -853,11 +861,13 @@ export abstract class BaseAgentAdapter implements AgentAdapter { sessionId: env.CODEMIE_SESSION_ID, version: env.CODEMIE_CLI_VERSION, profileConfig, - authMethod: (env.CODEMIE_AUTH_METHOD as 'sso' | 'jwt') || undefined, + authMethod: (env.CODEMIE_AUTH_METHOD === 'sso' || env.CODEMIE_AUTH_METHOD === 'jwt') ? env.CODEMIE_AUTH_METHOD : undefined, jwtToken: env.CODEMIE_JWT_TOKEN || undefined, repository, branch: branch || undefined, - project: env.CODEMIE_PROJECT || undefined + project: env.CODEMIE_PROJECT || undefined, + syncApiUrl: env.CODEMIE_SYNC_API_URL || undefined, + syncCodeMieUrl: env.CODEMIE_URL || undefined }; } diff --git a/src/agents/core/__tests__/BaseAgentAdapter.test.ts b/src/agents/core/__tests__/BaseAgentAdapter.test.ts index f06ace5b..c37ac267 100644 --- a/src/agents/core/__tests__/BaseAgentAdapter.test.ts +++ b/src/agents/core/__tests__/BaseAgentAdapter.test.ts @@ -1,7 +1,30 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { BaseAgentAdapter } from '../BaseAgentAdapter.js'; import type { AgentMetadata } from '../types.js'; +// Provide a minimal ProviderRegistry stub so shouldUseProxy can look up authType +// without needing real provider templates to be registered. +// registerProvider / registerSetupSteps / registerHealthCheck must also be stubbed +// because provider templates call them as side-effects when their modules are imported +// transitively through BaseAgentAdapter. +vi.mock('../../../providers/core/registry.js', () => { + const providers: Record = { + 'anthropic-subscription': { authType: 'none' }, + 'ai-run-sso': { authType: 'sso' }, + 'bearer-auth': { authType: 'jwt' }, + }; + return { + ProviderRegistry: { + registerProvider: vi.fn((t: any) => t), + registerSetupSteps: vi.fn(), + registerHealthCheck: vi.fn(), + registerModelProxy: vi.fn(), + getProvider: vi.fn((name: string) => providers[name]), + getProviderNames: vi.fn(() => Object.keys(providers)), + }, + }; +}); + /** * Test adapter that extends BaseAgentAdapter * Used to test protected methods and metadata access @@ -123,4 +146,136 @@ describe('BaseAgentAdapter', () => { expect(adapter.getMetadata().lifecycle).toBe(lifecycle); }); }); + + describe('proxy selection', () => { + // Shared metadata with ssoConfig enabled (same as the real claude plugin) + const proxyCapableMetadata: AgentMetadata = { + name: 'test', + displayName: 'Test Agent', + description: 'Test agent for unit testing', + npmPackage: null, + cliCommand: null, + envMapping: {}, + supportedProviders: ['anthropic-subscription', 'ai-run-sso', 'bearer-auth'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, + }; + + it('does not enable the model proxy just because CodeMie analytics sync is configured', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_URL: 'https://codemie.lab.epam.com', + CODEMIE_SYNC_API_URL: 'https://codemie.lab.epam.com/code-assistant-api', + })).toBe(false); + }); + + it('does not start proxy for authType:none even when CODEMIE_AUTH_METHOD=jwt is stale in env', () => { + // Regression guard for the stale-env contamination bug: + // A previous JWT session writes CODEMIE_AUTH_METHOD=jwt to process.env. + // The next anthropic-subscription run must NOT start the proxy. + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_AUTH_METHOD: 'jwt', // stale value from a prior JWT session + CODEMIE_URL: 'https://codemie.lab.epam.com', + CODEMIE_SYNC_API_URL: 'https://codemie.lab.epam.com/code-assistant-api', + })).toBe(false); + }); + + it('does not start proxy when CODEMIE_PROVIDER is absent', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + expect((adapter as any).shouldUseProxy({})).toBe(false); + }); + + it('starts proxy for SSO provider when ssoConfig is enabled', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'ai-run-sso', + })).toBe(true); + }); + + it('starts proxy for JWT auth method on a non-native provider', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'bearer-auth', + CODEMIE_AUTH_METHOD: 'jwt', + })).toBe(true); + }); + + it('does not start proxy when ssoConfig is disabled even for an SSO provider', () => { + const noProxyMetadata: AgentMetadata = { + ...proxyCapableMetadata, + ssoConfig: { enabled: false, clientType: 'codemie-claude' }, + }; + const adapter = new TestAdapter(noProxyMetadata); + + expect((adapter as any).shouldUseProxy({ + CODEMIE_PROVIDER: 'ai-run-sso', + })).toBe(false); + }); + }); + + describe('buildProxyConfig authMethod guard', () => { + const proxyCapableMetadata: AgentMetadata = { + name: 'test', + displayName: 'Test Agent', + description: 'Test agent for unit testing', + npmPackage: null, + cliCommand: null, + envMapping: {}, + supportedProviders: ['ai-run-sso'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, + }; + + const baseEnv = { + CODEMIE_BASE_URL: 'https://api.example.com', + CODEMIE_PROVIDER: 'ai-run-sso', + }; + + it('maps sso auth method correctly', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'sso', + }); + expect(config.authMethod).toBe('sso'); + }); + + it('maps jwt auth method correctly', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'jwt', + }); + expect(config.authMethod).toBe('jwt'); + }); + + it('sets authMethod to undefined for manual auth method', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'manual', + }); + expect(config.authMethod).toBeUndefined(); + }); + + it('sets authMethod to undefined when CODEMIE_AUTH_METHOD is not set', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig(baseEnv); + expect(config.authMethod).toBeUndefined(); + }); + + it('sets authMethod to undefined for unknown auth methods', () => { + const adapter = new TestAdapter(proxyCapableMetadata); + const config = (adapter as any).buildProxyConfig({ + ...baseEnv, + CODEMIE_AUTH_METHOD: 'api-key', + }); + expect(config.authMethod).toBeUndefined(); + }); + }); }); diff --git a/src/agents/core/__tests__/model-tier-config.test.ts b/src/agents/core/__tests__/model-tier-config.test.ts index fb8bed85..5c8cd31d 100644 --- a/src/agents/core/__tests__/model-tier-config.test.ts +++ b/src/agents/core/__tests__/model-tier-config.test.ts @@ -240,4 +240,76 @@ describe('ConfigLoader.exportProviderEnvVars', () => { expect(env.CODEMIE_SONNET_MODEL).toBeUndefined(); expect(env.CODEMIE_OPUS_MODEL).toBeUndefined(); }); + + it('should not export placeholder auth token for anthropic-subscription', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const config = { + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-6', + apiKey: '', + authMethod: 'manual' as const, + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'codemie-platform', + }; + + const env = ConfigLoader.exportProviderEnvVars(config); + + expect(env.CODEMIE_PROVIDER).toBe('anthropic-subscription'); + expect(env.CODEMIE_BASE_URL).toBe('https://api.anthropic.com'); + expect(env.CODEMIE_API_KEY).toBe(''); + expect(env.CODEMIE_MODEL).toBe('claude-sonnet-4-6'); + expect(env.CODEMIE_URL).toBe('https://codemie.lab.epam.com'); + expect(env.CODEMIE_SYNC_API_URL).toBe('https://codemie.lab.epam.com/code-assistant-api'); + expect(env.CODEMIE_PROJECT).toBe('codemie-platform'); + }); + + describe('CODEMIE_AUTH_METHOD export (stale-env contamination guard)', () => { + it('exports CODEMIE_AUTH_METHOD=manual for anthropic-subscription', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const env = ConfigLoader.exportProviderEnvVars({ + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual', + }); + + expect(env.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('exports CODEMIE_AUTH_METHOD="" when authMethod is not set', async () => { + const { ConfigLoader } = await import('../../../utils/config.js'); + + const env = ConfigLoader.exportProviderEnvVars({ + provider: 'openai', + baseUrl: 'https://api.openai.com', + apiKey: 'sk-test', + // authMethod intentionally absent + }); + + expect(env.CODEMIE_AUTH_METHOD).toBe(''); + }); + + it('CODEMIE_AUTH_METHOD overrides stale jwt value when merged into env', async () => { + // Simulates what BaseAgentAdapter does: + // env = { ...process.env (stale), ...envOverrides (fresh) } + // The fresh exportProviderEnvVars output must win over the stale value. + const { ConfigLoader } = await import('../../../utils/config.js'); + + const staleProcessEnv = { CODEMIE_AUTH_METHOD: 'jwt' }; + + const providerEnv = ConfigLoader.exportProviderEnvVars({ + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual', + }); + + const mergedEnv = { ...staleProcessEnv, ...providerEnv }; + + expect(mergedEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + }); }); diff --git a/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts new file mode 100644 index 00000000..1f6577bc --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.provider-support.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { ClaudePluginMetadata } from '../claude.plugin.js'; + +describe('ClaudePluginMetadata', () => { + it('supports anthropic-subscription provider', () => { + expect(ClaudePluginMetadata.supportedProviders).toContain('anthropic-subscription'); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index df644c8e..b71b9f58 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -84,7 +84,7 @@ export const ClaudePluginMetadata: AgentMetadata = { opusModel: ['ANTHROPIC_DEFAULT_OPUS_MODEL'], }, - supportedProviders: ['litellm', 'ai-run-sso', 'bedrock', 'bearer-auth'], + supportedProviders: ['litellm', 'ai-run-sso', 'bedrock', 'bearer-auth', 'anthropic-subscription'], blockedModelPatterns: [], recommendedModels: ['claude-sonnet-4-6', 'claude-4-opus', 'gpt-4.1'], diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 4bf264ec..2ce03493 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -70,6 +70,8 @@ export interface HookProcessingConfig { model?: string; /** SSO URL for credential loading */ ssoUrl?: string; + /** Optional dedicated CodeMie API URL for analytics sync */ + syncApiUrl?: string; } /** @@ -105,6 +107,7 @@ function getConfigValue(envKey: string, config?: HookProcessingConfig): string | 'CODEMIE_PROJECT': 'project', 'CODEMIE_MODEL': 'model', 'CODEMIE_URL': 'ssoUrl', + 'CODEMIE_SYNC_API_URL': 'syncApiUrl', }; const configKey = configMap[envKey]; if (configKey) { @@ -167,7 +170,7 @@ function initializeLoggerContext(): string { async function handleSessionStart(event: SessionStartEvent, _rawInput: string, sessionId: string, config?: HookProcessingConfig): Promise { // Create session record with correlation information await createSessionRecord(event, sessionId, config); - // Send session start metrics (SSO provider only) + // Send session start metrics when CodeMie analytics auth is configured await sendSessionStartMetrics(event, sessionId, event.session_id, config); // Sync CodeMie skills to Claude Code (.claude/skills/) await syncSkillsToClaude(event.cwd || process.cwd()); @@ -234,10 +237,13 @@ async function handleSessionEnd(event: SessionEndEvent, sessionId: string, confi */ async function syncPendingDataToAPI(sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only sync for SSO provider const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionEnd] Skipping API sync (not SSO provider)'); + const ssoUrl = getConfigValue('CODEMIE_URL', config); + const syncApiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config); + const hasCodeMieAnalyticsAuth = Boolean(ssoUrl && syncApiUrl); + + if (provider !== 'ai-run-sso' && !hasCodeMieAnalyticsAuth) { + logger.debug('[hook:SessionEnd] Skipping API sync (CodeMie analytics auth not configured)'); return; } @@ -377,9 +383,8 @@ async function buildProcessingContext( config?: HookProcessingConfig ): Promise { // Get configuration values from config object or environment variables - const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config) || ''; + const apiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config) || getConfigValue('CODEMIE_BASE_URL', config) || ''; const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config) || '0.0.0'; const clientType = getConfigValue('CODEMIE_CLIENT_TYPE', config) || 'codemie-cli'; @@ -387,8 +392,10 @@ async function buildProcessingContext( let cookies = config?.cookies || ''; let apiKey: string | undefined = config?.apiKey; - // If SSO provider and credentials not provided in config, try to load them - if (provider === 'ai-run-sso' && ssoUrl && apiUrl && !cookies) { + // If CodeMie analytics auth is configured and credentials are not provided, + // try to load the stored SSO cookies. This also supports native providers + // such as anthropic-subscription, where CodeMie auth is analytics-only. + if (ssoUrl && apiUrl && !cookies) { try { const { CodeMieSSO } = await import('../../providers/plugins/sso/sso.auth.js'); const sso = new CodeMieSSO(); @@ -690,7 +697,7 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, /** * Helper: Send session start metrics to CodeMie backend - * Only works with ai-run-sso provider + * Works for providers that have CodeMie analytics authentication configured * * @param event - SessionStart event data * @param sessionId - The CodeMie session ID (for file operations) @@ -699,17 +706,12 @@ async function createSessionRecord(event: SessionStartEvent, sessionId: string, */ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only send metrics for SSO provider - const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionStart] Skipping metrics (not SSO provider)'); - return; - } - // Get required configuration values const agentName = getConfigValue('CODEMIE_AGENT', config); + const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config); + const syncApiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config); + const apiUrl = syncApiUrl || getConfigValue('CODEMIE_BASE_URL', config); const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config); const model = getConfigValue('CODEMIE_MODEL', config); const project = getConfigValue('CODEMIE_PROJECT', config); @@ -768,7 +770,13 @@ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: stri } if (!cookieHeader) { - logger.info(`[hook:SessionStart] No SSO credentials available for ${ssoUrl}`); + if (syncApiUrl) { + logger.info( + `[hook:SessionStart] CodeMie analytics sync is configured for ${syncApiUrl}, but no stored credentials were found. Run: codemie profile login --url ${ssoUrl}` + ); + } else { + logger.info(`[hook:SessionStart] No SSO credentials available for ${ssoUrl}`); + } return; } @@ -799,7 +807,7 @@ async function sendSessionStartMetrics(event: SessionStartEvent, sessionId: stri { sessionId: agentSessionId, agentName, - provider, + provider: provider || 'unknown', project, model, startTime: Date.now(), @@ -950,7 +958,7 @@ async function renameSessionFiles(sessionId: string): Promise { /** * Helper: Send session end metrics to CodeMie backend - * Only works with ai-run-sso provider + * Works for providers that have CodeMie analytics authentication configured * * @param event - SessionEnd event data * @param sessionId - The CodeMie session ID (for file operations) @@ -959,17 +967,11 @@ async function renameSessionFiles(sessionId: string): Promise { */ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, agentSessionId: string, config?: HookProcessingConfig): Promise { try { - // Only send metrics for SSO provider - const provider = getConfigValue('CODEMIE_PROVIDER', config); - if (provider !== 'ai-run-sso') { - logger.debug('[hook:SessionEnd] Skipping metrics (not SSO provider)'); - return; - } - // Get required configuration values const agentName = getConfigValue('CODEMIE_AGENT', config); + const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); - const apiUrl = getConfigValue('CODEMIE_BASE_URL', config); + const apiUrl = getConfigValue('CODEMIE_SYNC_API_URL', config) || getConfigValue('CODEMIE_BASE_URL', config); const cliVersion = getConfigValue('CODEMIE_CLI_VERSION', config); const model = getConfigValue('CODEMIE_MODEL', config); const project = getConfigValue('CODEMIE_PROJECT', config); @@ -1044,7 +1046,7 @@ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, { sessionId: agentSessionId, agentName, - provider, + provider: provider || 'unknown', project, model, startTime: session.startTime, diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 94d7b79a..daf376e2 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -257,7 +257,17 @@ async function handlePluginSetup( } // Step 3: Model selection - const selectedModel = await promptForModelSelection(models, providerTemplate); + let selectedModel: string; + const preselectedModel = setupSteps.selectModel + ? await setupSteps.selectModel(credentials, models, providerTemplate) + : undefined; + + if (preselectedModel) { + selectedModel = preselectedModel; + logger.success(`Model selected automatically: ${selectedModel}`); + } else { + selectedModel = await promptForModelSelection(models, providerTemplate); + } // Step 3.5: Install model if provider supports it (e.g., Ollama) if (providerTemplate?.supportsModelInstallation && setupSteps.installModel) { diff --git a/src/providers/core/__tests__/codemie-auth-helpers.test.ts b/src/providers/core/__tests__/codemie-auth-helpers.test.ts new file mode 100644 index 00000000..542a5dfa --- /dev/null +++ b/src/providers/core/__tests__/codemie-auth-helpers.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ConfigurationError } from '../../../utils/errors.js'; + +// Mock HTTPClient before importing the module under test +const mockGetRaw = vi.fn(); +vi.mock('../base/http-client.js', () => ({ + HTTPClient: class { + getRaw = mockGetRaw; + }, +})); + +vi.mock('../../../utils/logger.js', () => ({ + logger: { success: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn() }, +})); + +import { ensureApiBase, buildAuthHeaders, fetchCodeMieUserInfo, selectCodeMieProject } from '../codemie-auth-helpers.js'; + +describe('ensureApiBase', () => { + it('appends /code-assistant-api when missing', () => { + expect(ensureApiBase('https://codemie.example.com')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('removes trailing slash before appending suffix', () => { + expect(ensureApiBase('https://codemie.example.com/')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('does not double-append when suffix already present', () => { + expect(ensureApiBase('https://codemie.example.com/code-assistant-api')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('does not double-append when suffix present with trailing slash', () => { + expect(ensureApiBase('https://codemie.example.com/code-assistant-api/')).toBe( + 'https://codemie.example.com/code-assistant-api' + ); + }); + + it('handles path prefix before /code-assistant-api', () => { + const url = 'https://codemie.example.com/prefix/code-assistant-api'; + expect(ensureApiBase(url)).toBe(url); + }); +}); + +describe('buildAuthHeaders', () => { + it('builds cookie headers from SSO cookies object', () => { + const headers = buildAuthHeaders({ session: 'abc', token: 'xyz' }); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-CodeMie-Client']).toBe('codemie-cli'); + expect(headers.cookie).toBe('session=abc;token=xyz'); + expect(headers.authorization).toBeUndefined(); + }); + + it('builds Bearer authorization header from JWT string', () => { + const headers = buildAuthHeaders('my-jwt-token'); + + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-CodeMie-Client']).toBe('codemie-cli'); + expect(headers.authorization).toBe('Bearer my-jwt-token'); + expect(headers.cookie).toBeUndefined(); + }); + + it('includes CLI version in User-Agent and X-CodeMie-CLI headers', () => { + process.env.CODEMIE_CLI_VERSION = '1.2.3'; + const headers = buildAuthHeaders('token'); + + expect(headers['User-Agent']).toBe('codemie-cli/1.2.3'); + expect(headers['X-CodeMie-CLI']).toBe('codemie-cli/1.2.3'); + delete process.env.CODEMIE_CLI_VERSION; + }); + + it('falls back to unknown when CODEMIE_CLI_VERSION is not set', () => { + delete process.env.CODEMIE_CLI_VERSION; + const headers = buildAuthHeaders('token'); + + expect(headers['User-Agent']).toBe('codemie-cli/unknown'); + }); +}); + +describe('fetchCodeMieUserInfo', () => { + beforeEach(() => { + mockGetRaw.mockReset(); + }); + + it('throws ConfigurationError on 401 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 401, + statusMessage: 'Unauthorized', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Authentication failed - invalid or expired credentials'); + }); + + it('throws ConfigurationError on 403 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 403, + statusMessage: 'Forbidden', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', 'jwt-token') + ).rejects.toThrow(ConfigurationError); + }); + + it('throws ConfigurationError on 500 response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 500, + statusMessage: 'Internal Server Error', + data: '', + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Failed to fetch user info: 500 Internal Server Error'); + }); + + it('throws ConfigurationError when response is missing applications arrays', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ userId: '1', name: 'Test', username: 'test' }), + }); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow(ConfigurationError); + + await expect( + fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }) + ).rejects.toThrow('Invalid user info response: missing applications arrays'); + }); + + it('normalizes applicationsAdmin to applications_admin', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['proj-a'], + applicationsAdmin: ['proj-b'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }); + expect(result.applications_admin).toEqual(['proj-b']); + }); + + it('returns user info on successful response', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test User', + username: 'tuser', + isAdmin: false, + applications: ['project-a', 'project-b'], + applications_admin: ['project-a'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await fetchCodeMieUserInfo('https://api.example.com', { session: 'abc' }); + expect(result.userId).toBe('1'); + expect(result.applications).toEqual(['project-a', 'project-b']); + expect(result.applications_admin).toEqual(['project-a']); + }); +}); + +describe('selectCodeMieProject', () => { + beforeEach(() => { + mockGetRaw.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('throws ConfigurationError when apiUrl is missing', async () => { + await expect( + selectCodeMieProject({ cookies: { session: 'abc' } } as any) + ).rejects.toThrow(ConfigurationError); + }); + + it('throws ConfigurationError when cookies are missing', async () => { + await expect( + selectCodeMieProject({ apiUrl: 'https://api.example.com' } as any) + ).rejects.toThrow(ConfigurationError); + }); + + it('auto-selects single project and prints to console', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['only-project'], + applications_admin: [], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any); + + expect(result).toBe('only-project'); + // Verify console.log was called (interactive UX feedback) + expect(console.log).toHaveBeenCalled(); + const logCall = (console.log as any).mock.calls[0][0]; + expect(logCall).toContain('Auto-selected project'); + }); + + it('throws ConfigurationError when no projects are found', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: [], + applications_admin: [], + picture: '', + knowledgeBases: [], + }), + }); + + await expect( + selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any) + ).rejects.toThrow('No projects found for your account'); + }); + + it('deduplicates projects from applications and applications_admin', async () => { + mockGetRaw.mockResolvedValue({ + statusCode: 200, + statusMessage: 'OK', + data: JSON.stringify({ + userId: '1', + name: 'Test', + username: 'test', + isAdmin: false, + applications: ['shared-project'], + applications_admin: ['shared-project'], + picture: '', + knowledgeBases: [], + }), + }); + + const result = await selectCodeMieProject({ + apiUrl: 'https://api.example.com', + cookies: { session: 'abc' }, + } as any); + + // Only one project after dedup → auto-selected + expect(result).toBe('shared-project'); + }); +}); diff --git a/src/providers/core/codemie-auth-helpers.ts b/src/providers/core/codemie-auth-helpers.ts new file mode 100644 index 00000000..9f654a32 --- /dev/null +++ b/src/providers/core/codemie-auth-helpers.ts @@ -0,0 +1,178 @@ +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import { HTTPClient } from './base/http-client.js'; +import type { SSOAuthResult } from './types.js'; +import { ConfigurationError } from '../../utils/errors.js'; + +export const DEFAULT_CODEMIE_BASE_URL = 'https://codemie.lab.epam.com'; + +export interface CodeMieUserInfo { + userId: string; + name: string; + username: string; + isAdmin: boolean; + applications: string[]; + applications_admin: string[]; + applicationsAdmin?: string[]; + picture: string; + knowledgeBases: string[]; + userType?: string; +} + +export function ensureApiBase(rawUrl: string): string { + let base = rawUrl.replace(/\/$/, ''); + if (!/\/code-assistant-api(\/|$)/i.test(base)) { + base = `${base}/code-assistant-api`; + } + return base; +} + +export function buildAuthHeaders(auth: Record | string): Record { + const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `codemie-cli/${cliVersion}`, + 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, + 'X-CodeMie-Client': 'codemie-cli' + }; + + if (typeof auth === 'string') { + headers.authorization = `Bearer ${auth}`; + } else { + headers.cookie = Object.entries(auth) + .map(([key, value]) => `${key}=${value}`) + .join(';'); + } + + return headers; +} + +export async function promptForCodeMieUrl( + defaultUrl: string = DEFAULT_CODEMIE_BASE_URL, + message: string = 'CodeMie organization URL:' +): Promise { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'codeMieUrl', + message, + default: defaultUrl, + validate: (input: string) => { + if (!input.trim()) { + return 'CodeMie URL is required'; + } + if (!input.startsWith('http://') && !input.startsWith('https://')) { + return 'Please enter a valid URL starting with http:// or https://'; + } + return true; + } + } + ]); + + return answers.codeMieUrl.trim(); +} + +export async function authenticateWithCodeMie( + codeMieUrl: string, + timeout: number = 120000 +): Promise { + const { CodeMieSSO } = await import('../plugins/sso/sso.auth.js'); + const sso = new CodeMieSSO(); + return sso.authenticate({ + codeMieUrl, + timeout + }); +} + +/* eslint-disable no-redeclare */ +export function fetchCodeMieUserInfo( + apiUrl: string, + cookies: Record +): Promise; +export function fetchCodeMieUserInfo( + apiUrl: string, + jwtToken: string +): Promise; +export async function fetchCodeMieUserInfo( + apiUrl: string, + auth: Record | string +): Promise { +/* eslint-enable no-redeclare */ + const headers = buildAuthHeaders(auth); + const url = `${apiUrl}/v1/user`; + + const client = new HTTPClient({ + timeout: 10000, + maxRetries: 3, + // Enterprise on-premises CodeMie deployments commonly use self-signed certificates. + rejectUnauthorized: false + }); + + const response = await client.getRaw(url, headers); + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode === 401 || response.statusCode === 403) { + throw new ConfigurationError('Authentication failed - invalid or expired credentials'); + } + throw new ConfigurationError(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); + } + + const userInfo = JSON.parse(response.data) as CodeMieUserInfo; + + if (!Array.isArray(userInfo.applications_admin) && Array.isArray(userInfo.applicationsAdmin)) { + userInfo.applications_admin = userInfo.applicationsAdmin; + } + + if (!userInfo || !Array.isArray(userInfo.applications) || !Array.isArray(userInfo.applications_admin)) { + throw new ConfigurationError('Invalid user info response: missing applications arrays'); + } + + return userInfo; +} + +export async function selectCodeMieProject(authResult: SSOAuthResult): Promise { + if (!authResult.apiUrl || !authResult.cookies) { + throw new ConfigurationError('API URL or cookies not found in authentication result'); + } + + const userInfo = await fetchCodeMieUserInfo( + authResult.apiUrl, + authResult.cookies + ); + + const applications = userInfo.applications || []; + const applicationsAdmin = userInfo.applications_admin || []; + const allProjects = [...new Set([...applications, ...applicationsAdmin])]; + + if (allProjects.length === 0) { + throw new ConfigurationError('No projects found for your account. Please contact your administrator.'); + } + + const sortedProjects = allProjects.sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }) + ); + + if (sortedProjects.length === 1) { + const selectedProject = sortedProjects[0]; + console.log(chalk.green(`āœ“ Auto-selected project: ${chalk.bold(selectedProject)}`)); + return selectedProject; + } + + console.log(chalk.dim(`Found ${sortedProjects.length} accessible project(s)`)); + + const projectAnswers = await inquirer.prompt([ + { + type: 'list', + name: 'project', + message: 'Select your project:', + choices: sortedProjects.map(project => ({ + name: project, + value: project + })), + pageSize: 15 + } + ]); + + console.log(chalk.green(`āœ“ Selected project: ${chalk.bold(projectAnswers.project)}`)); + return projectAnswers.project; +} diff --git a/src/providers/core/index.ts b/src/providers/core/index.ts index 66d80b78..592a6ead 100644 --- a/src/providers/core/index.ts +++ b/src/providers/core/index.ts @@ -33,3 +33,12 @@ export { BaseHealthCheck } from './base/BaseHealthCheck.js'; export { BaseModelProxy } from './base/BaseModelProxy.js'; export { HTTPClient } from './base/http-client.js'; export type { HTTPClientConfig, HTTPResponse } from './base/http-client.js'; +export { + DEFAULT_CODEMIE_BASE_URL, + fetchCodeMieUserInfo, + ensureApiBase, + buildAuthHeaders, + promptForCodeMieUrl, + authenticateWithCodeMie, + selectCodeMieProject +} from './codemie-auth-helpers.js'; diff --git a/src/providers/core/types.ts b/src/providers/core/types.ts index ba84d3f7..d515b503 100644 --- a/src/providers/core/types.ts +++ b/src/providers/core/types.ts @@ -283,6 +283,15 @@ export interface ProviderSetupSteps { */ fetchModels(credentials: ProviderCredentials): Promise; + /** + * Optional: choose model programmatically and skip interactive model selection + */ + selectModel?( + credentials: ProviderCredentials, + models: string[], + template?: ProviderTemplate + ): Promise; + /** * Step 3: Build final configuration * diff --git a/src/providers/index.ts b/src/providers/index.ts index 09577838..83cd62c5 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -36,6 +36,7 @@ import './plugins/sso/index.js'; import './plugins/jwt/index.js'; import './plugins/litellm/index.js'; import './plugins/bedrock/index.js'; +import './plugins/anthropic-subscription/index.js'; // Re-export plugin modules for direct access if needed export * as Ollama from './plugins/ollama/index.js'; @@ -43,3 +44,4 @@ export * as SSO from './plugins/sso/index.js'; export * as JWT from './plugins/jwt/index.js'; export * as LiteLLM from './plugins/litellm/index.js'; export * as Bedrock from './plugins/bedrock/index.js'; +export * as AnthropicSubscription from './plugins/anthropic-subscription/index.js'; diff --git a/src/providers/integration/__tests__/setup-ui.test.ts b/src/providers/integration/__tests__/setup-ui.test.ts new file mode 100644 index 00000000..a10adf8f --- /dev/null +++ b/src/providers/integration/__tests__/setup-ui.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { ProviderTemplate } from '../../core/types.js'; +import { getAllProviderChoices } from '../setup-ui.js'; + +describe('setup-ui', () => { + describe('getAllProviderChoices', () => { + it('sorts providers by priority and keeps Anthropic Subscription before Ollama', () => { + const providers: ProviderTemplate[] = [ + { + name: 'ollama', + displayName: 'Ollama', + description: 'Local models', + defaultBaseUrl: 'http://localhost:11434', + requiresAuth: false, + authType: 'none', + recommendedModels: ['qwen2.5-coder'], + capabilities: ['streaming'], + supportsModelInstallation: true, + }, + { + name: 'anthropic-subscription', + displayName: 'Anthropic Subscription', + description: 'Native Claude Code authentication', + defaultBaseUrl: 'https://api.anthropic.com', + requiresAuth: false, + authType: 'none', + priority: 16, + recommendedModels: ['claude-sonnet-4-6'], + capabilities: ['streaming', 'tools'], + supportsModelInstallation: false, + }, + { + name: 'ai-run-sso', + displayName: 'CodeMie SSO', + description: 'Enterprise SSO Authentication', + defaultBaseUrl: 'https://codemie.lab.epam.com', + requiresAuth: true, + authType: 'sso', + priority: 0, + recommendedModels: ['claude-sonnet-4-6'], + capabilities: ['streaming', 'tools', 'sso-auth'], + supportsModelInstallation: false, + } + ]; + + const choices = getAllProviderChoices(providers); + + expect(choices.map(choice => choice.value)).toEqual([ + 'ai-run-sso', + 'anthropic-subscription', + 'ollama', + ]); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts new file mode 100644 index 00000000..4be18acb --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.auth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { parseClaudeAuthStatus } from '../anthropic-subscription.auth.js'; +import { ConfigurationError } from '../../../../utils/errors.js'; + +describe('parseClaudeAuthStatus', () => { + it('parses logged-in Claude auth status', () => { + const status = parseClaudeAuthStatus(JSON.stringify({ + loggedIn: true, + authMethod: 'oauth', + apiProvider: 'firstParty' + })); + + expect(status.loggedIn).toBe(true); + expect(status.authMethod).toBe('oauth'); + expect(status.apiProvider).toBe('firstParty'); + }); + + it('parses logged-out Claude auth status', () => { + const status = parseClaudeAuthStatus(JSON.stringify({ + loggedIn: false, + authMethod: 'none', + apiProvider: 'firstParty' + })); + + expect(status.loggedIn).toBe(false); + expect(status.authMethod).toBe('none'); + }); + + it('throws ConfigurationError for invalid JSON', () => { + expect(() => parseClaudeAuthStatus('not-json')).toThrow(ConfigurationError); + expect(() => parseClaudeAuthStatus('not-json')).toThrow( + 'Failed to parse Claude auth status output' + ); + }); + + it('throws ConfigurationError for empty string', () => { + expect(() => parseClaudeAuthStatus('')).toThrow(ConfigurationError); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts new file mode 100644 index 00000000..beec6665 --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.setup-steps.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { AnthropicSubscriptionSetupSteps } from '../anthropic-subscription.setup-steps.js'; +import { AnthropicSubscriptionTemplate } from '../anthropic-subscription.template.js'; + +describe('AnthropicSubscriptionSetupSteps', () => { + describe('selectModel', () => { + it('auto-selects the first model when CodeMie analytics is enabled', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { + additionalConfig: { + codeMieUrl: 'https://codemie.lab.epam.com' + } + }, + ['claude-sonnet-4-6', 'claude-opus-4-6'] + ); + + expect(selectedModel).toBe('claude-sonnet-4-6'); + }); + + it('keeps interactive model selection when CodeMie analytics is not enabled', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { + additionalConfig: {} + }, + ['claude-sonnet-4-6', 'claude-opus-4-6'] + ); + + expect(selectedModel).toBeNull(); + }); + + it('falls back to template recommended model when models list is empty and CodeMie URL is set', async () => { + const selectedModel = await AnthropicSubscriptionSetupSteps.selectModel?.( + { additionalConfig: { codeMieUrl: 'https://codemie.lab.epam.com' } }, + [] + ); + + expect(selectedModel).toBe(AnthropicSubscriptionTemplate.recommendedModels[0]); + }); + }); + + describe('fetchModels', () => { + it('returns the template recommended models list', async () => { + const models = await AnthropicSubscriptionSetupSteps.fetchModels({}); + + expect(models).toEqual(AnthropicSubscriptionTemplate.recommendedModels); + }); + }); + + describe('buildConfig', () => { + it('builds config with provider and model when no CodeMie URL is configured', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { + baseUrl: 'https://api.anthropic.com', + apiKey: '', + additionalConfig: { authMethod: 'manual' } + }, + 'claude-sonnet-4-6' + ); + + expect(config.provider).toBe('anthropic-subscription'); + expect(config.model).toBe('claude-sonnet-4-6'); + expect(config.apiKey).toBe(''); + expect(config.authMethod).toBe('manual'); + expect(config.codeMieUrl).toBeUndefined(); + expect(config.codeMieProject).toBeUndefined(); + }); + + it('includes codeMieUrl and codeMieProject when CodeMie analytics is enabled', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { + baseUrl: 'https://api.anthropic.com', + apiKey: '', + additionalConfig: { + authMethod: 'manual', + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'my-project' + } + }, + 'claude-opus-4-6' + ); + + expect(config.provider).toBe('anthropic-subscription'); + expect(config.model).toBe('claude-opus-4-6'); + expect(config.codeMieUrl).toBe('https://codemie.lab.epam.com'); + expect(config.codeMieProject).toBe('my-project'); + }); + + it('falls back to template defaultBaseUrl when credentials have no baseUrl', () => { + const config = AnthropicSubscriptionSetupSteps.buildConfig( + { additionalConfig: { authMethod: 'manual' } }, + 'claude-sonnet-4-6' + ); + + expect(config.baseUrl).toBe(AnthropicSubscriptionTemplate.defaultBaseUrl); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts new file mode 100644 index 00000000..0119fd4b --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/__tests__/anthropic-subscription.template.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { AnthropicSubscriptionTemplate } from '../anthropic-subscription.template.js'; + +const { mockInstall, mockGetAgent } = vi.hoisted(() => ({ + mockInstall: vi.fn(), + mockGetAgent: vi.fn(), +})); + +vi.mock('../../../../agents/registry.js', () => ({ + AgentRegistry: { getAgent: mockGetAgent }, +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})); + +describe('AnthropicSubscriptionTemplate', () => { + it('has the correct provider name', () => { + expect(AnthropicSubscriptionTemplate.name).toBe('anthropic-subscription'); + }); + + it('requires no API key (authType none)', () => { + expect(AnthropicSubscriptionTemplate.requiresAuth).toBe(false); + expect(AnthropicSubscriptionTemplate.authType).toBe('none'); + }); + + it('points to the Anthropic API base URL', () => { + expect(AnthropicSubscriptionTemplate.defaultBaseUrl).toBe('https://api.anthropic.com'); + }); + + it('includes recommended Claude models', () => { + expect(AnthropicSubscriptionTemplate.recommendedModels).toContain('claude-sonnet-4-6'); + expect(AnthropicSubscriptionTemplate.recommendedModels.length).toBeGreaterThan(0); + }); + + describe('agentHooks - beforeRun (*)', () => { + beforeEach(() => { + mockInstall.mockResolvedValue({ success: true, targetPath: '/tmp/codemie-claude-plugin' }); + mockGetAgent.mockReturnValue({ getExtensionInstaller: () => ({ install: mockInstall }) }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('strips Anthropic auth vars and does not mutate the original env', async () => { + const env: Record = { + ANTHROPIC_AUTH_TOKEN: 'some-token', + ANTHROPIC_API_KEY: 'some-key', + ANTHROPIC_BASE_URL: 'http://localhost:1234', + OTHER_VAR: 'keep-me', + }; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(result.OTHER_VAR).toBe('keep-me'); + + // Must not mutate the caller's object + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('some-token'); + expect(env.ANTHROPIC_API_KEY).toBe('some-key'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:1234'); + }); + + it('sets CODEMIE_CLAUDE_EXTENSION_DIR when installer succeeds', async () => { + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!({}, { agent: 'claude' }); + + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBe('/tmp/codemie-claude-plugin'); + }); + + it('returns env unchanged for non-Claude agents', async () => { + const env: Record = { + ANTHROPIC_AUTH_TOKEN: 'some-token', + OTHER_VAR: 'keep-me', + }; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'gemini' }); + + expect(result).toBe(env); // exact same reference — no copy created + }); + + it('strips vars and continues when agent is not in the registry', async () => { + mockGetAgent.mockReturnValue(undefined); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + + it('strips vars and continues when agent has no extension installer', async () => { + mockGetAgent.mockReturnValue({ getExtensionInstaller: () => undefined }); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + + it('still sets CODEMIE_CLAUDE_EXTENSION_DIR when install reports failure', async () => { + mockInstall.mockResolvedValue({ success: false, targetPath: '/tmp/codemie-claude-plugin', error: 'disk full' }); + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!({}, { agent: 'claude' }); + + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBe('/tmp/codemie-claude-plugin'); + }); + + it('strips vars and does not throw when installer throws', async () => { + mockInstall.mockRejectedValue(new Error('ENOENT')); + + const env: Record = { ANTHROPIC_API_KEY: 'key' }; + const hook = AnthropicSubscriptionTemplate.agentHooks?.['*']; + const result = await hook!.beforeRun!(env, { agent: 'claude' }); + + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.CODEMIE_CLAUDE_EXTENSION_DIR).toBeUndefined(); + }); + }); + + describe('agentHooks - enrichArgs (claude)', () => { + const originalEnv = process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + } else { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = originalEnv; + } + }); + + it('prepends --plugin-dir when CODEMIE_CLAUDE_EXTENSION_DIR is set', () => { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = '/tmp/codemie-claude-plugin'; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = hook!.enrichArgs!(['--verbose'], { agent: 'claude' }); + + expect(result).toEqual(['--plugin-dir', '/tmp/codemie-claude-plugin', '--verbose']); + }); + + it('returns args unchanged when CODEMIE_CLAUDE_EXTENSION_DIR is not set', () => { + delete process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = hook!.enrichArgs!(['--verbose'], { agent: 'claude' }); + + expect(result).toEqual(['--verbose']); + }); + + it('does not inject --plugin-dir when it is already present in args', () => { + process.env.CODEMIE_CLAUDE_EXTENSION_DIR = '/tmp/codemie-claude-plugin'; + + const hook = AnthropicSubscriptionTemplate.agentHooks?.['claude']; + const result = hook!.enrichArgs!(['--plugin-dir', '/custom/path', '--verbose'], { agent: 'claude' }); + + expect(result).toEqual(['--plugin-dir', '/custom/path', '--verbose']); + }); + }); + + describe('exportEnvVars', () => { + it('always exports CODEMIE_API_KEY as empty string', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); + expect(env.CODEMIE_API_KEY).toBe(''); + }); + + it('exports CODEMIE_URL and CODEMIE_SYNC_API_URL when codeMieUrl is set', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({ codeMieUrl: 'https://codemie.example.com' } as any); + + expect(env.CODEMIE_URL).toBe('https://codemie.example.com'); + expect(env.CODEMIE_SYNC_API_URL).toContain('code-assistant-api'); + }); + + it('exports CODEMIE_PROJECT when codeMieProject is set', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({ + codeMieUrl: 'https://codemie.example.com', + codeMieProject: 'my-project', + } as any); + + expect(env.CODEMIE_PROJECT).toBe('my-project'); + }); + + it('omits CODEMIE_URL and CODEMIE_PROJECT when not configured', () => { + const env = AnthropicSubscriptionTemplate.exportEnvVars!({} as any); + + expect(env.CODEMIE_URL).toBeUndefined(); + expect(env.CODEMIE_PROJECT).toBeUndefined(); + }); + }); +}); diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts new file mode 100644 index 00000000..da7fb99a --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.auth.ts @@ -0,0 +1,39 @@ +import { commandExists } from '../../../utils/processes.js'; +import { exec } from '../../../utils/exec.js'; +import { AgentInstallationError, ConfigurationError } from '../../../utils/errors.js'; + +interface ClaudeAuthStatus { + loggedIn?: boolean; + authMethod?: string; + apiProvider?: string; +} + +export async function ensureClaudeCliAvailable(): Promise { + const hasClaude = await commandExists('claude'); + if (!hasClaude) { + throw new AgentInstallationError('claude', 'Claude Code CLI is not installed. Run: codemie install claude'); + } +} + +export async function runClaudeBrowserLogin(): Promise { + await exec('claude', ['auth', 'login'], { + timeout: 300000, + interactive: true + }); +} + +export function parseClaudeAuthStatus(raw: string): ClaudeAuthStatus { + try { + return JSON.parse(raw) as ClaudeAuthStatus; + } catch { + throw new ConfigurationError('Failed to parse Claude auth status output'); + } +} + +export async function getClaudeAuthStatus(): Promise { + const result = await exec('claude', ['auth', 'status', '--json'], { + timeout: 10000 + }); + + return parseClaudeAuthStatus(result.stdout); +} diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts new file mode 100644 index 00000000..3c87d2d5 --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.setup-steps.ts @@ -0,0 +1,125 @@ +/** + * Anthropic Subscription Setup Steps + * + * Interactive setup flow for native Claude Code authentication. + */ + +import inquirer from 'inquirer'; +import type { ProviderCredentials, ProviderSetupSteps } from '../../core/types.js'; +import { ProviderRegistry } from '../../core/registry.js'; +import type { CodeMieConfigOptions } from '../../../env/types.js'; +import { logger } from '../../../utils/logger.js'; +import { ConfigurationError } from '../../../utils/errors.js'; +import { AnthropicSubscriptionTemplate } from './anthropic-subscription.template.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + authenticateWithCodeMie, + promptForCodeMieUrl, + selectCodeMieProject +} from '../../core/codemie-auth-helpers.js'; +import { + ensureClaudeCliAvailable, + getClaudeAuthStatus, + runClaudeBrowserLogin +} from './anthropic-subscription.auth.js'; + +export const AnthropicSubscriptionSetupSteps: ProviderSetupSteps = { + name: 'anthropic-subscription', + + async getCredentials(_isUpdate = false): Promise { + logger.info('Anthropic Subscription Setup'); + logger.info('This provider uses Claude Code native browser authentication.'); + logger.info('CodeMie will not store an Anthropic API key for this profile.'); + + await ensureClaudeCliAvailable(); + + const currentStatus = await getClaudeAuthStatus(); + if (!currentStatus.loggedIn) { + logger.info('Claude Code is not authenticated yet.'); + logger.info('Launching native Claude browser login...'); + await runClaudeBrowserLogin(); + } else { + logger.success('Claude Code is already authenticated'); + } + + const finalStatus = await getClaudeAuthStatus(); + if (!finalStatus.loggedIn) { + throw new ConfigurationError('Claude Code authentication is required. Complete `claude auth login` and run setup again.'); + } + + logger.success(`Claude native auth confirmed (${finalStatus.authMethod || 'unknown'})`); + + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'enableCodeMieAnalytics', + message: 'Login to CodeMie platform to enable analytics sync?', + default: false + } + ]); + + let codeMieUrl: string | undefined; + let codeMieProject: string | undefined; + + if (answers.enableCodeMieAnalytics) { + codeMieUrl = await promptForCodeMieUrl( + DEFAULT_CODEMIE_BASE_URL, + 'CodeMie platform URL for analytics sync:' + ); + + logger.info('Authenticating to CodeMie platform...'); + const authResult = await authenticateWithCodeMie(codeMieUrl, 120000); + + if (!authResult.success) { + throw new ConfigurationError(`CodeMie authentication failed: ${authResult.error || 'Unknown error'}`); + } + + logger.success('CodeMie authentication successful'); + logger.info('Fetching available projects...'); + codeMieProject = await selectCodeMieProject(authResult); + logger.success('Analytics sync enabled for CodeMie platform'); + } + + return { + baseUrl: AnthropicSubscriptionTemplate.defaultBaseUrl, + apiKey: '', + additionalConfig: { + authMethod: 'manual', + codeMieUrl, + codeMieProject + } + }; + }, + + async fetchModels(_credentials: ProviderCredentials): Promise { + return [...AnthropicSubscriptionTemplate.recommendedModels]; + }, + + async selectModel( + credentials: ProviderCredentials, + models: string[] + ): Promise { + if (credentials.additionalConfig?.codeMieUrl) { + return models[0] || AnthropicSubscriptionTemplate.recommendedModels[0] || 'claude-sonnet-4-6'; + } + + return null; + }, + + buildConfig( + credentials: ProviderCredentials, + selectedModel: string + ): Partial { + return { + provider: 'anthropic-subscription', + baseUrl: credentials.baseUrl || AnthropicSubscriptionTemplate.defaultBaseUrl, + apiKey: '', + model: selectedModel, + authMethod: 'manual', + codeMieUrl: credentials.additionalConfig?.codeMieUrl as string | undefined, + codeMieProject: credentials.additionalConfig?.codeMieProject as string | undefined, + }; + } +}; + +ProviderRegistry.registerSetupSteps('anthropic-subscription', AnthropicSubscriptionSetupSteps); diff --git a/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts new file mode 100644 index 00000000..99fb8d1c --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/anthropic-subscription.template.ts @@ -0,0 +1,134 @@ +/** + * Anthropic Subscription Provider Template + * + * Template definition for native Claude Code authentication using + * an existing Anthropic subscription login. + * + * Auto-registers on import via registerProvider(). + */ + +import type { ProviderTemplate } from '../../core/types.js'; +import type { AgentConfig } from '../../../agents/core/types.js'; +import { registerProvider } from '../../core/decorators.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; + +export const AnthropicSubscriptionTemplate = registerProvider({ + name: 'anthropic-subscription', + displayName: 'Anthropic Subscription', + description: 'Native Claude Code authentication using your Claude subscription', + defaultBaseUrl: 'https://api.anthropic.com', + requiresAuth: false, + authType: 'none', + priority: 16, + defaultProfileName: 'anthropic-subscription', + recommendedModels: [ + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-4-5-haiku', + ], + capabilities: ['streaming', 'tools', 'function-calling', 'vision'], + supportsModelInstallation: false, + supportsStreaming: true, + + agentHooks: { + '*': { + async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig): Promise { + if (config.agent !== 'claude') { + return env; + } + + // Return a copy so callers that hold a reference to the original env are not affected. + const updated = { ...env }; + + // Native Claude subscription auth relies on Claude Code's stored login. + // Explicit Anthropic API/proxy env vars override that flow and can cause 401s. + delete updated.ANTHROPIC_AUTH_TOKEN; + delete updated.ANTHROPIC_API_KEY; + delete updated.ANTHROPIC_BASE_URL; + + // Reuse the Claude Code plugin hooks so local metrics/conversation files are + // produced even though model traffic is not proxied through CodeMie. + // + // Dynamic import avoids a circular dependency: AgentRegistry imports all plugins + // (including this provider template) as side effects, so a static top-level import + // here would form a cycle. The dynamic import defers resolution until runtime when + // the registry is already fully initialised. + try { + const { AgentRegistry } = await import('../../../agents/registry.js'); + const agent = AgentRegistry.getAgent('claude'); + const installer = agent?.getExtensionInstaller?.(); + + if (installer) { + const result = await installer.install(); + updated.CODEMIE_CLAUDE_EXTENSION_DIR = result.targetPath; + + if (!result.success) { + const { logger } = await import('../../../utils/logger.js'); + logger.warn(`[claude] Extension installation returned failure: ${result.error || 'unknown error'}`); + logger.warn('[claude] Continuing without extension - hooks may not be available'); + } + } + } catch (error) { + const { logger } = await import('../../../utils/logger.js'); + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error(`[claude] Extension installation threw exception: ${errorMsg}`); + logger.warn('[claude] Continuing without extension - hooks may not be available'); + } + + return updated; + } + }, + 'claude': { + enrichArgs(args: string[], _config: AgentConfig): string[] { + const pluginDir = process.env.CODEMIE_CLAUDE_EXTENSION_DIR; + + if (!pluginDir || args.some(arg => arg === '--plugin-dir')) { + return args; + } + + return ['--plugin-dir', pluginDir, ...args]; + } + } + }, + + // Claude Code should use its own stored login/session instead of a placeholder token. + exportEnvVars: (config) => { + const env: Record = { + // transformEnvVars() runs before beforeRun(), and beforeRun() removes agent auth vars + // for native Claude auth before the Claude process is spawned. + CODEMIE_API_KEY: '', + }; + + if (config.codeMieUrl) { + env.CODEMIE_URL = config.codeMieUrl; + env.CODEMIE_SYNC_API_URL = ensureApiBase(config.codeMieUrl); + } + if (config.codeMieProject) { + env.CODEMIE_PROJECT = config.codeMieProject; + } + + return env; + }, + + setupInstructions: ` +# Anthropic Subscription Setup Instructions + +Use this option when Claude Code is already authenticated with your Anthropic account +and you want CodeMie to use that native login flow directly. + +## Prerequisites + +1. Install Claude Code +2. Authenticate Claude Code with your Anthropic subscription + +\`\`\`bash +claude auth login +\`\`\` + +## Notes + +- No API key is stored in CodeMie for this provider +- Claude Code uses its existing local authentication/session +- This provider is intended for native \`codemie-claude\` usage +` +}); diff --git a/src/providers/plugins/anthropic-subscription/index.ts b/src/providers/plugins/anthropic-subscription/index.ts new file mode 100644 index 00000000..014e35ab --- /dev/null +++ b/src/providers/plugins/anthropic-subscription/index.ts @@ -0,0 +1,10 @@ +/** + * Anthropic Subscription Provider - Complete Provider Implementation + * + * Native Claude Code authentication via an existing Anthropic subscription. + * Auto-registers with ProviderRegistry on import. + */ + +export { AnthropicSubscriptionTemplate } from './anthropic-subscription.template.js'; +export { AnthropicSubscriptionSetupSteps } from './anthropic-subscription.setup-steps.js'; + diff --git a/src/providers/plugins/jwt/jwt.setup-steps.ts b/src/providers/plugins/jwt/jwt.setup-steps.ts index f037350b..2a6da1ee 100644 --- a/src/providers/plugins/jwt/jwt.setup-steps.ts +++ b/src/providers/plugins/jwt/jwt.setup-steps.ts @@ -13,7 +13,7 @@ import type { AuthValidationResult } from '../../core/types.js'; import type { CodeMieConfigOptions } from '../../../env/types.js'; -import { ensureApiBase } from '../sso/sso.http-client.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; export const JWTBearerSetupSteps: ProviderSetupSteps = { name: 'bearer-auth', diff --git a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts index c9327ae5..4323c58e 100644 --- a/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/sso.session-sync.plugin.ts @@ -25,6 +25,7 @@ import { logger } from '../../../../../utils/logger.js'; import type { ProcessingContext } from '../../session/BaseProcessor.js'; import { SessionSyncer } from '../../session/SessionSyncer.js'; import type { SSOCredentials } from '../../../../core/types.js'; +import { ConfigurationError } from '../../../../../utils/errors.js'; export class SSOSessionSyncPlugin implements ProxyPlugin { id = '@codemie/sso-session-sync'; @@ -33,28 +34,30 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { priority = 100; // Run after logging (priority 50) async createInterceptor(context: PluginContext): Promise { + const syncCredentials = context.syncCredentials || context.credentials; + // Only create interceptor if we have necessary context if (!context.config.sessionId) { logger.debug('[SSOSessionSyncPlugin] Skipping: Session ID not available'); - throw new Error('Session ID not available (session sync disabled)'); + throw new ConfigurationError('Session ID not available (session sync disabled)'); } // Guard: skip if credentials are JWT (not SSO) - if (!context.credentials || !('cookies' in context.credentials)) { + if (!syncCredentials || !('cookies' in syncCredentials)) { logger.debug('[SSOSessionSyncPlugin] Skipping: Not SSO credentials'); - throw new Error('SSO credentials not available (session sync disabled)'); + throw new ConfigurationError('SSO credentials not available (session sync disabled)'); } if (!context.config.clientType) { logger.debug('[SSOSessionSyncPlugin] Skipping: Client type not available'); - throw new Error('Client type not available (session sync disabled)'); + throw new ConfigurationError('Client type not available (session sync disabled)'); } // Check if sync is enabled (from config or env var) const syncEnabled = this.isSyncEnabled(context); if (!syncEnabled) { logger.debug('[SSOSessionSyncPlugin] Skipping: Session sync disabled by configuration'); - throw new Error('Session sync disabled by configuration'); + throw new ConfigurationError('Session sync disabled by configuration'); } logger.debug('[SSOSessionSyncPlugin] Initializing unified session sync'); @@ -63,11 +66,11 @@ export class SSOSessionSyncPlugin implements ProxyPlugin { const dryRun = this.isDryRunEnabled(context); // Cast credentials to SSOCredentials (already validated above) - const ssoCredentials = context.credentials as SSOCredentials; + const ssoCredentials = syncCredentials as SSOCredentials; return new SSOSessionSyncInterceptor( context.config.sessionId, - context.config.targetApiUrl, + context.config.syncApiUrl || context.config.targetApiUrl, ssoCredentials.cookies, context.config.clientType, context.config.version, diff --git a/src/providers/plugins/sso/proxy/plugins/types.ts b/src/providers/plugins/sso/proxy/plugins/types.ts index 0f690d9a..110e8f80 100644 --- a/src/providers/plugins/sso/proxy/plugins/types.ts +++ b/src/providers/plugins/sso/proxy/plugins/types.ts @@ -48,6 +48,7 @@ export interface PluginContext { config: ProxyConfig; logger: typeof logger; credentials?: SSOCredentials | JWTCredentials; + syncCredentials?: SSOCredentials | JWTCredentials; profileConfig?: CodeMieConfigOptions; // Full profile config (read once at CLI level) [key: string]: unknown; // Extensible } diff --git a/src/providers/plugins/sso/proxy/proxy-types.ts b/src/providers/plugins/sso/proxy/proxy-types.ts index fca8b75c..77892b9e 100644 --- a/src/providers/plugins/sso/proxy/proxy-types.ts +++ b/src/providers/plugins/sso/proxy/proxy-types.ts @@ -28,6 +28,8 @@ export interface ProxyConfig { repository?: string; // Repository name (parent/current format) for header injection branch?: string; // Git branch at startup for header injection project?: string; // CodeMie project name for header injection + syncApiUrl?: string; // Optional CodeMie API URL for analytics/session sync + syncCodeMieUrl?: string; // Optional CodeMie org URL for credential lookup } /** diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index f0264c36..75a12b88 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -26,6 +26,7 @@ import { createServer, Server, IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'crypto'; import { URL } from 'url'; import { ProviderRegistry } from '../../../core/registry.js'; +import type { JWTCredentials, SSOCredentials } from '../../../core/types.js'; import { logger } from '../../../../utils/logger.js'; import { ProxyHTTPClient } from './proxy-http-client.js'; import { ProxyConfig, ProxyContext } from './proxy-types.js'; @@ -60,7 +61,8 @@ export class CodeMieProxy { const authMethod = this.config.authMethod || 'sso'; // Default: SSO for backward compat // 2. Load credentials based on auth method - let credentials: any = null; + let credentials: SSOCredentials | JWTCredentials | null = null; + let syncCredentials: SSOCredentials | JWTCredentials | null = null; if (authMethod === 'jwt') { // JWT path: token from CLI arg, env var, or credential store @@ -94,11 +96,23 @@ export class CodeMieProxy { } } + if (this.config.syncCodeMieUrl) { + const { CodeMieSSO } = await import('../sso.auth.js'); + const sso = new CodeMieSSO(); + syncCredentials = await sso.getStoredCredentials(this.config.syncCodeMieUrl); + if (!syncCredentials) { + logger.debug( + `[CodeMieProxy] Analytics sync is configured for ${this.config.syncCodeMieUrl}, but no stored credentials were found. Re-authenticate with: codemie profile login --url ${this.config.syncCodeMieUrl}` + ); + } + } + // 3. Build plugin context (includes profile config read once at CLI level) const pluginContext: PluginContext = { config: this.config, logger, credentials: credentials || undefined, + syncCredentials: syncCredentials || undefined, profileConfig: this.config.profileConfig }; diff --git a/src/providers/plugins/sso/sso.auth.ts b/src/providers/plugins/sso/sso.auth.ts index a90efe6b..1bc6858a 100644 --- a/src/providers/plugins/sso/sso.auth.ts +++ b/src/providers/plugins/sso/sso.auth.ts @@ -11,6 +11,7 @@ import open from 'open'; import chalk from 'chalk'; import type { SSOAuthConfig, SSOAuthResult, SSOCredentials } from '../../core/types.js'; import { CredentialStore } from '../../../utils/security.js'; +import { ensureApiBase } from '../../core/codemie-auth-helpers.js'; /** * Normalize URL to base (protocol + host) @@ -68,7 +69,7 @@ export class CodeMieSSO { const port = await this.startLocalServer(); // 2. Construct SSO URL (following plugin pattern) - const codeMieBase = this.ensureApiBase(config.codeMieUrl); + const codeMieBase = ensureApiBase(config.codeMieUrl); const ssoUrl = `${codeMieBase}/v1/auth/login/${port}`; // 3. Launch browser @@ -167,18 +168,6 @@ export class CodeMieSSO { await store.clearSSOCredentials(baseUrl); } - /** - * Ensure API base URL has correct format - */ - private ensureApiBase(rawUrl: string): string { - let base = rawUrl.replace(/\/$/, ''); - // If user entered only host, append the known API context - if (!/\/code-assistant-api(\/|$)/i.test(base)) { - base = `${base}/code-assistant-api`; - } - return base; - } - /** * Start local HTTP server for OAuth callback */ @@ -295,7 +284,7 @@ export class CodeMieSSO { } // Try to fetch config.js to resolve actual API URL - let apiUrl = this.ensureApiBase(this.codeMieUrl); + let apiUrl = ensureApiBase(this.codeMieUrl); try { const configResponse = await fetch(`${apiUrl}/config.js`, { headers: { diff --git a/src/providers/plugins/sso/sso.http-client.ts b/src/providers/plugins/sso/sso.http-client.ts index 32c8b737..5ca5126f 100644 --- a/src/providers/plugins/sso/sso.http-client.ts +++ b/src/providers/plugins/sso/sso.http-client.ts @@ -4,24 +4,14 @@ * CodeMie-specific HTTP client with SSO cookie handling */ -import { HTTPClient } from '../../core/base/http-client.js'; import type { CodeMieModel, CodeMieIntegration, CodeMieIntegrationsResponse } from '../../core/types.js'; - -/** - * User info response from /v1/user endpoint - */ -export interface CodeMieUserInfo { - userId: string; - name: string; - username: string; - isAdmin: boolean; - applications: string[]; - applications_admin: string[]; - applicationsAdmin?: string[]; - picture: string; - knowledgeBases: string[]; - userType?: string; -} +import { HTTPClient } from '../../core/base/http-client.js'; +import { + fetchCodeMieUserInfo, + buildAuthHeaders +} from '../../core/codemie-auth-helpers.js'; +export { fetchCodeMieUserInfo }; +export type { CodeMieUserInfo } from '../../core/codemie-auth-helpers.js'; /** * CodeMie API endpoints @@ -35,52 +25,6 @@ export const CODEMIE_ENDPOINTS = { AUTH_LOGIN: '/v1/auth/login' } as const; -/** - * Internal helper: build auth headers from cookies or JWT token - */ -function buildAuthHeaders(auth: Record | string): Record { - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - const headers: Record = { - 'Content-Type': 'application/json', - 'User-Agent': `codemie-cli/${cliVersion}`, - 'X-CodeMie-CLI': `codemie-cli/${cliVersion}`, - 'X-CodeMie-Client': 'codemie-cli' - }; - - if (typeof auth === 'string') { - // JWT token (string) - headers['authorization'] = `Bearer ${auth}`; - } else { - // SSO cookies (object) - existing behavior - headers['cookie'] = Object.entries(auth) - .map(([key, value]) => `${key}=${value}`) - .join(';'); - } - - return headers; -} - -/** - * Ensure API base URL has correct format with /code-assistant-api suffix - * - * @param rawUrl - Raw URL from user input (e.g., https://codemie.lab.epam.com) - * @returns Normalized URL with /code-assistant-api suffix - * - * @example - * ensureApiBase('https://codemie.lab.epam.com') - * // => 'https://codemie.lab.epam.com/code-assistant-api' - * - * ensureApiBase('https://codemie.lab.epam.com/code-assistant-api') - * // => 'https://codemie.lab.epam.com/code-assistant-api' - */ -export function ensureApiBase(rawUrl: string): string { - let base = rawUrl.replace(/\/$/, ''); // Remove trailing slash - // If URL doesn't have /code-assistant-api suffix, append it - if (!/\/code-assistant-api(\/|$)/i.test(base)) { - base = `${base}/code-assistant-api`; - } - return base; -} /** * Full model descriptor returned by GET /v1/llm_models?include_all=true @@ -182,8 +126,8 @@ export async function fetchCodeMieModels( const url = `${apiUrl}${CODEMIE_ENDPOINTS.MODELS}`; const client = new HTTPClient({ - timeout: 10000, - maxRetries: 3, + timeout: 30000, + maxRetries: 5, rejectUnauthorized: false }); @@ -224,67 +168,6 @@ export async function fetchCodeMieModels( return filteredModels; } -/** - * Fetch user information including accessible applications (supports both cookies and JWT) - * - * @param apiUrl - CodeMie API base URL - * @param auth - SSO session cookies or JWT token - * @returns User info with applications array - * @throws Error if request fails or response invalid - * - * Overload 1: SSO cookies (backward compatible - existing callers unchanged) - * Overload 2: JWT token string (new) - */ -/* eslint-disable no-redeclare */ -export function fetchCodeMieUserInfo( - apiUrl: string, - cookies: Record -): Promise; -export function fetchCodeMieUserInfo( - apiUrl: string, - jwtToken: string -): Promise; -export async function fetchCodeMieUserInfo( - apiUrl: string, - auth: Record | string -): Promise { - const headers = buildAuthHeaders(auth); - const url = `${apiUrl}${CODEMIE_ENDPOINTS.USER}`; - - const client = new HTTPClient({ - timeout: 10000, - maxRetries: 3, - rejectUnauthorized: false - }); - - const response = await client.getRaw(url, headers); - - // Handle HTTP errors - if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - if (response.statusCode === 401 || response.statusCode === 403) { - throw new Error('Authentication failed - invalid or expired credentials'); - } - throw new Error(`Failed to fetch user info: ${response.statusCode} ${response.statusMessage}`); - } - - // Parse response - const userInfo = JSON.parse(response.data) as CodeMieUserInfo; - - // Normalize applications_admin: support both snake_case and camelCase variants - // applications_admin has higher priority; fall back to applicationsAdmin if missing - if (!Array.isArray(userInfo.applications_admin) && Array.isArray(userInfo.applicationsAdmin)) { - userInfo.applications_admin = userInfo.applicationsAdmin; - } - - // Validate response structure - if (!userInfo || !Array.isArray(userInfo.applications) || !Array.isArray(userInfo.applications_admin)) { - throw new Error('Invalid user info response: missing applications arrays'); - } - - return userInfo; -} -/* eslint-enable no-redeclare */ - /** * Fetch application details (non-blocking, best-effort) - supports both cookies and JWT * diff --git a/src/providers/plugins/sso/sso.setup-steps.ts b/src/providers/plugins/sso/sso.setup-steps.ts index 173d5a49..82b97a19 100644 --- a/src/providers/plugins/sso/sso.setup-steps.ts +++ b/src/providers/plugins/sso/sso.setup-steps.ts @@ -20,11 +20,16 @@ import type { } from '../../core/types.js'; import type { CodeMieConfigOptions, CodeMieIntegrationInfo } from '../../../env/types.js'; import { ProviderRegistry } from '../../core/registry.js'; -import { SSOTemplate } from './sso.template.js'; import { CodeMieSSO } from './sso.auth.js'; import { SSOModelProxy } from './sso.models.js'; -import { fetchCodeMieUserInfo, fetchCodeMieModels, fetchCodeMieIntegrations } from './sso.http-client.js'; +import { fetchCodeMieModels, fetchCodeMieIntegrations } from './sso.http-client.js'; import { logger } from '../../../utils/logger.js'; +import { + DEFAULT_CODEMIE_BASE_URL, + authenticateWithCodeMie, + promptForCodeMieUrl, + selectCodeMieProject +} from '../../core/codemie-auth-helpers.js'; /** * SSO setup steps implementation @@ -38,34 +43,11 @@ export const SSOSetupSteps: ProviderSetupSteps = { * Prompts for CodeMie URL and performs browser-based authentication */ async getCredentials(): Promise { - // Prompt for CodeMie URL - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'codeMieUrl', - message: 'CodeMie organization URL:', - default: SSOTemplate.defaultBaseUrl, - validate: (input: string) => { - if (!input.trim()) { - return 'CodeMie URL is required'; - } - if (!input.startsWith('http://') && !input.startsWith('https://')) { - return 'Please enter a valid URL starting with http:// or https://'; - } - return true; - } - } - ]); - - const codeMieUrl = answers.codeMieUrl.trim(); + const codeMieUrl = await promptForCodeMieUrl(DEFAULT_CODEMIE_BASE_URL); // Authenticate via browser console.log(chalk.cyan('\nšŸ” Authenticating via browser...\n')); - const sso = new CodeMieSSO(); - const authResult = await sso.authenticate({ - codeMieUrl, - timeout: 120000 // 2 minutes - }); + const authResult = await authenticateWithCodeMie(codeMieUrl, 120000); if (!authResult.success) { throw new Error(`SSO authentication failed: ${authResult.error || 'Unknown error'}`); @@ -84,51 +66,7 @@ export const SSOSetupSteps: ProviderSetupSteps = { throw new Error('API URL or cookies not found in authentication result'); } - // Fetch user's accessible applications - const userInfo = await fetchCodeMieUserInfo( - authResult.apiUrl, - authResult.cookies - ); - - // Merge applications and applicationsAdmin arrays (deduplicated) - const applications = userInfo.applications || []; - const applicationsAdmin = userInfo.applications_admin || []; - const allProjects = [...new Set([...applications, ...applicationsAdmin])]; - - // Validate that user has at least one project - if (allProjects.length === 0) { - throw new Error('No projects found for your account. Please contact your administrator.'); - } - - // Sort projects alphabetically (case-insensitive) - const sortedProjects = allProjects.sort((a, b) => - a.localeCompare(b, undefined, { sensitivity: 'base' }) - ); - - // Auto-select if only one project - if (sortedProjects.length === 1) { - selectedProject = sortedProjects[0]; - console.log(chalk.green(`āœ“ Auto-selected project: ${chalk.bold(selectedProject)}\n`)); - } else { - // Multiple projects - prompt user to select - console.log(chalk.dim(`Found ${sortedProjects.length} accessible project(s)\n`)); - - const projectAnswers = await inquirer.prompt([ - { - type: 'list', - name: 'project', - message: 'Select your project:', - choices: sortedProjects.map(proj => ({ - name: proj, - value: proj - })), - pageSize: 15 - } - ]); - - selectedProject = projectAnswers.project; - console.log(chalk.green(`āœ“ Selected project: ${chalk.bold(selectedProject)}\n`)); - } + selectedProject = await selectCodeMieProject(authResult); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.log(chalk.red(`āœ— Project selection failed: ${errorMsg}\n`)); diff --git a/src/providers/plugins/sso/sso.template.ts b/src/providers/plugins/sso/sso.template.ts index bb5077b3..def6aa81 100644 --- a/src/providers/plugins/sso/sso.template.ts +++ b/src/providers/plugins/sso/sso.template.ts @@ -10,12 +10,13 @@ import type { ProviderTemplate } from '../../core/types.js'; import type { AgentConfig } from '../../../agents/core/types.js'; import { registerProvider } from '../../core/index.js'; +import { DEFAULT_CODEMIE_BASE_URL } from '../../core/codemie-auth-helpers.js'; export const SSOTemplate = registerProvider({ name: 'ai-run-sso', displayName: 'CodeMie SSO', description: 'Enterprise SSO Authentication with centralized model management', - defaultBaseUrl: 'https://codemie.lab.epam.com', // Default CodeMie URL + defaultBaseUrl: DEFAULT_CODEMIE_BASE_URL, requiresAuth: true, authType: 'sso', priority: 0, // Highest priority (shown first) diff --git a/src/utils/config.ts b/src/utils/config.ts index e57205eb..29fb4c94 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -96,6 +96,18 @@ export class ConfigLoader { delete filteredEnvConfig.provider; filtered.push('provider'); } + if (!cliOverrides.codeMieUrl && filteredEnvConfig.codeMieUrl) { + delete filteredEnvConfig.codeMieUrl; + filtered.push('codeMieUrl'); + } + if (!cliOverrides.authMethod && filteredEnvConfig.authMethod) { + delete filteredEnvConfig.authMethod; + filtered.push('authMethod'); + } + if (!cliOverrides.codeMieIntegration && filteredEnvConfig.codeMieIntegration) { + delete filteredEnvConfig.codeMieIntegration; + filtered.push('codeMieIntegration'); + } if (filtered.length > 0 && config.debug) { console.log(`[ConfigLoader] Profile protection: filtered environment vars: ${filtered.join(', ')}`); @@ -1115,6 +1127,12 @@ export class ConfigLoader { if (config.timeout) env.CODEMIE_TIMEOUT = String(config.timeout); if (config.debug) env.CODEMIE_DEBUG = String(config.debug); + // Always export CODEMIE_AUTH_METHOD so that a stale 'jwt' value written to + // process.env by a previous JWT-authenticated session cannot bleed into the + // current session and trigger proxy usage for non-JWT providers. + // Falls back to '' (no auth method) when the provider doesn't set one. + env.CODEMIE_AUTH_METHOD = config.authMethod ?? ''; + // Provider-specific environment variables (pluggable) // Each provider defines its own exportEnvVars function if (providerTemplate?.exportEnvVars) { diff --git a/tests/integration/proxy-routing-guard.test.ts b/tests/integration/proxy-routing-guard.test.ts new file mode 100644 index 00000000..40145e4d --- /dev/null +++ b/tests/integration/proxy-routing-guard.test.ts @@ -0,0 +1,137 @@ +/** + * Integration tests: proxy routing guard for anthropic-subscription + * + * Regression suite for the stale-CODEMIE_AUTH_METHOD bug: + * + * A previous JWT-authenticated session writes CODEMIE_AUTH_METHOD=jwt to + * process.env via Object.assign(process.env, env) in BaseAgentAdapter.run(). + * The next anthropic-subscription run must NOT start the CodeMie proxy even + * though the stale env var is present. + * + * Two defences are exercised together (as in production): + * 1. exportProviderEnvVars always emits CODEMIE_AUTH_METHOD so envOverrides + * always wins over stale process.env values. + * 2. shouldUseProxy early-returns false for providers with authType === 'none'. + * + * @group integration + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ConfigLoader } from '../../src/utils/config.js'; +import { BaseAgentAdapter } from '../../src/agents/core/BaseAgentAdapter.js'; +import type { AgentMetadata } from '../../src/agents/core/types.js'; + +// Importing the template registers anthropic-subscription in ProviderRegistry +// so that shouldUseProxy can resolve its authType without mocks. +import '../../src/providers/plugins/anthropic-subscription/index.js'; + +// --------------------------------------------------------------------------- +// Minimal concrete adapter used to call the private shouldUseProxy method +// --------------------------------------------------------------------------- +class TestAdapter extends BaseAgentAdapter { + async run(): Promise { /* no-op */ } + + callShouldUseProxy(env: NodeJS.ProcessEnv): boolean { + return (this as any).shouldUseProxy(env); + } +} + +const adapterMetadata: AgentMetadata = { + name: 'claude', + displayName: 'Claude Code', + description: 'Test adapter', + npmPackage: null, + cliCommand: null, + envMapping: { baseUrl: ['ANTHROPIC_BASE_URL'], apiKey: ['ANTHROPIC_AUTH_TOKEN'] }, + supportedProviders: ['anthropic-subscription', 'ai-run-sso', 'bearer-auth'], + ssoConfig: { enabled: true, clientType: 'codemie-claude' }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const STALE_JWT_ENV: NodeJS.ProcessEnv = { CODEMIE_AUTH_METHOD: 'jwt' }; + +const ANTHROPIC_SUBSCRIPTION_CONFIG = { + provider: 'anthropic-subscription', + baseUrl: 'https://api.anthropic.com', + apiKey: '', + authMethod: 'manual' as const, + model: 'claude-sonnet-4-6', + codeMieUrl: 'https://codemie.lab.epam.com', + codeMieProject: 'my-project', +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('Proxy routing guard — anthropic-subscription with stale JWT env', () => { + let savedAuthMethod: string | undefined; + + beforeEach(() => { + savedAuthMethod = process.env.CODEMIE_AUTH_METHOD; + }); + + afterEach(() => { + if (savedAuthMethod === undefined) { + delete process.env.CODEMIE_AUTH_METHOD; + } else { + process.env.CODEMIE_AUTH_METHOD = savedAuthMethod; + } + }); + + it('exportProviderEnvVars emits CODEMIE_AUTH_METHOD=manual for anthropic-subscription', () => { + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + expect(providerEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('merging exportProviderEnvVars over stale process.env neutralises CODEMIE_AUTH_METHOD=jwt', () => { + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + + // Replicate what BaseAgentAdapter.run() does at line ~471 + const mergedEnv: NodeJS.ProcessEnv = { ...STALE_JWT_ENV, ...providerEnv }; + + expect(mergedEnv.CODEMIE_AUTH_METHOD).toBe('manual'); + }); + + it('shouldUseProxy returns false for anthropic-subscription even when CODEMIE_AUTH_METHOD=jwt is stale in process.env', () => { + // Simulate a stale env from a previous JWT session + process.env.CODEMIE_AUTH_METHOD = 'jwt'; + + const providerEnv = ConfigLoader.exportProviderEnvVars(ANTHROPIC_SUBSCRIPTION_CONFIG); + + // Replicate BaseAgentAdapter.run() env assembly + const env: NodeJS.ProcessEnv = { ...process.env, ...providerEnv }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(false); + }); + + it('shouldUseProxy returns false for anthropic-subscription even with explicit CODEMIE_AUTH_METHOD=jwt in env', () => { + // Defence-in-depth: authType:none guard fires before isJWTAuth is checked + const env: NodeJS.ProcessEnv = { + CODEMIE_PROVIDER: 'anthropic-subscription', + CODEMIE_AUTH_METHOD: 'jwt', + }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(false); + }); + + it('shouldUseProxy still returns true for an SSO provider (regression)', () => { + const env: NodeJS.ProcessEnv = { CODEMIE_PROVIDER: 'ai-run-sso' }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(true); + }); + + it('shouldUseProxy still returns true for bearer-auth with CODEMIE_AUTH_METHOD=jwt (regression)', () => { + const env: NodeJS.ProcessEnv = { + CODEMIE_PROVIDER: 'bearer-auth', + CODEMIE_AUTH_METHOD: 'jwt', + }; + + const adapter = new TestAdapter(adapterMetadata); + expect(adapter.callShouldUseProxy(env)).toBe(true); + }); +});