From 1f5d11930ee19c0f00b46d7994ea99c7be8e55c6 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Mon, 26 Jan 2026 16:19:35 +0900 Subject: [PATCH 1/3] feat: skip local OAuth when using remote proxy with auth token When --proxy-host and --proxy-auth-token are provided, the remote proxy handles authentication via its own OAuth sessions. This change skips: - Local OAuth check/trigger - Preflight quota check (managed by remote server) - Model configuration prompts (configured on remote server) - Broken model warnings (model selection is remote) This enables headless CI/CD usage without requiring pre-cached CCS_SESSIONS when a remote proxy with auth token is available. --- src/cliproxy/cliproxy-executor.ts | 40 ++-- tests/unit/cliproxy/skip-local-auth.test.ts | 213 ++++++++++++++++++++ 2 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 tests/unit/cliproxy/skip-local-auth.test.ts diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index bea75a25..c74453f5 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -498,7 +498,14 @@ export async function execClaudeWithCLIProxy( } // 3. Ensure OAuth completed (if provider requires it) - if (providerConfig.requiresOAuth) { + // Skip local OAuth check when using remote proxy with auth token + // The remote proxy has its own OAuth sessions and handles authentication + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + if (skipLocalAuth) { + log(`Using remote proxy authentication (skipping local OAuth)`); + } + + if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); if (forceAuth || !isAuthenticated(provider)) { @@ -549,7 +556,8 @@ export async function execClaudeWithCLIProxy( // 3b. Preflight quota check - auto-switch to account with quota before launch // Uses quota-manager for caching, tier priority, and cooldown support - if (provider === 'agy') { + // Skip for remote proxy - quota is managed on the remote server + if (provider === 'agy' && !skipLocalAuth) { const preflight = await preflightCheck(provider); if (!preflight.proceed) { @@ -571,23 +579,27 @@ export async function execClaudeWithCLIProxy( // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants - if (supportsModelConfig(provider)) { + // Skip for remote proxy - model is configured on the remote server + if (supportsModelConfig(provider) && !skipLocalAuth) { await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured } // 5. Check for known broken models and warn user - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); + // Skip for remote proxy - model selection is on the remote server + if (!skipLocalAuth) { + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + console.error(` Run "ccs ${provider} --config" to change model.`); + console.error(''); } - console.error(` Run "ccs ${provider} --config" to change model.`); - console.error(''); } // 6. Ensure user settings file exists (creates from defaults if not) diff --git a/tests/unit/cliproxy/skip-local-auth.test.ts b/tests/unit/cliproxy/skip-local-auth.test.ts new file mode 100644 index 00000000..e840e032 --- /dev/null +++ b/tests/unit/cliproxy/skip-local-auth.test.ts @@ -0,0 +1,213 @@ +/** + * Unit tests for skip-local-auth functionality when using remote proxy with auth token + * + * When --proxy-host and --proxy-auth-token are provided together, the system should + * skip local OAuth checks because the remote proxy handles authentication. + */ +import { describe, it, expect } from 'bun:test'; + +describe('skip-local-auth logic', () => { + describe('skipLocalAuth flag determination', () => { + it('should skip local auth when both useRemoteProxy and authToken are truthy', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: 'test-token-123' }; + + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + + expect(skipLocalAuth).toBeTruthy(); + }); + + it('should NOT skip local auth when useRemoteProxy is false', () => { + const useRemoteProxy = false; + const proxyConfig = { authToken: 'test-token-123' }; + + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + + expect(skipLocalAuth).toBeFalsy(); + }); + + it('should NOT skip local auth when authToken is undefined', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: undefined }; + + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + + expect(skipLocalAuth).toBeFalsy(); + }); + + it('should NOT skip local auth when authToken is empty string', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: '' }; + + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + + expect(skipLocalAuth).toBeFalsy(); + }); + + it('should NOT skip local auth when both are falsy', () => { + const useRemoteProxy = false; + const proxyConfig = { authToken: undefined }; + + const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + + expect(skipLocalAuth).toBeFalsy(); + }); + }); + + describe('OAuth check bypass scenarios', () => { + it('should document that OAuth is skipped for remote proxy with auth', () => { + // This test documents the expected behavior: + // When using remote proxy with auth token, the remote server + // already has its own OAuth sessions, so local OAuth is unnecessary + const scenario = { + useRemoteProxy: true, + authToken: 'bearer-token', + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeTruthy(); + expect(shouldTriggerLocalOAuth).toBe(false); + }); + + it('should document that OAuth runs when no remote proxy', () => { + const scenario = { + useRemoteProxy: false, + authToken: undefined, + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeFalsy(); + expect(shouldTriggerLocalOAuth).toBe(true); + }); + + it('should document that OAuth runs when remote proxy has no auth token', () => { + // Edge case: remote proxy configured but no auth token + // This should fall back to local OAuth + const scenario = { + useRemoteProxy: true, + authToken: undefined, + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeFalsy(); + expect(shouldTriggerLocalOAuth).toBe(true); + }); + }); + + describe('preflight quota check bypass', () => { + it('should skip preflight for agy provider when using remote proxy with auth', () => { + const provider = 'agy'; + const skipLocalAuth = true; + + const shouldRunPreflight = provider === 'agy' && !skipLocalAuth; + + expect(shouldRunPreflight).toBe(false); + }); + + it('should run preflight for agy provider when using local mode', () => { + const provider = 'agy'; + const skipLocalAuth = false; + + const shouldRunPreflight = provider === 'agy' && !skipLocalAuth; + + expect(shouldRunPreflight).toBe(true); + }); + + it('should not run preflight for non-agy providers regardless of mode', () => { + const providers = ['gemini', 'codex', 'copilot', 'kiro']; + + for (const provider of providers) { + const shouldRunPreflight = provider === 'agy' && !false; + expect(shouldRunPreflight).toBe(false); + } + }); + }); + + describe('model configuration bypass', () => { + it('should skip model config when using remote proxy with auth', () => { + const supportsModelConfig = true; + const skipLocalAuth = true; + + const shouldConfigureModel = supportsModelConfig && !skipLocalAuth; + + expect(shouldConfigureModel).toBe(false); + }); + + it('should run model config when using local mode', () => { + const supportsModelConfig = true; + const skipLocalAuth = false; + + const shouldConfigureModel = supportsModelConfig && !skipLocalAuth; + + expect(shouldConfigureModel).toBe(true); + }); + }); + + describe('broken model warning bypass', () => { + it('should skip broken model warning when using remote proxy with auth', () => { + const skipLocalAuth = true; + const currentModel = 'some-broken-model'; + const isModelBroken = true; + + // Logic: only warn if NOT skipping local auth + const shouldWarn = !skipLocalAuth && currentModel && isModelBroken; + + expect(shouldWarn).toBe(false); + }); + + it('should show broken model warning when using local mode', () => { + const skipLocalAuth = false; + const currentModel = 'some-broken-model'; + const isModelBroken = true; + + const shouldWarn = !skipLocalAuth && currentModel && isModelBroken; + + expect(shouldWarn).toBe(true); + }); + }); + + describe('CI/CD workflow scenarios', () => { + it('should enable headless CI workflow with remote proxy', () => { + // Simulate GitHub Actions workflow configuration + const workflowConfig = { + headless: true, + proxyHost: 'proxy.example.com', + proxyPort: 443, + proxyProtocol: 'https', + proxyAuthToken: 'github-secret-token', + remoteOnly: true, + }; + + // Determine if this configuration should skip local OAuth + const useRemoteProxy = !!workflowConfig.proxyHost; + const skipLocalAuth = useRemoteProxy && !!workflowConfig.proxyAuthToken; + + expect(useRemoteProxy).toBe(true); + expect(skipLocalAuth).toBe(true); + }); + + it('should require local OAuth when no proxy configured', () => { + // Simulate local development without proxy + const localConfig = { + headless: false, + proxyHost: undefined, + proxyAuthToken: undefined, + }; + + const useRemoteProxy = !!localConfig.proxyHost; + const skipLocalAuth = useRemoteProxy && !!localConfig.proxyAuthToken; + + expect(useRemoteProxy).toBe(false); + expect(skipLocalAuth).toBe(false); + }); + }); +}); From 21e819b59062b77c2686ecc5f24e9c3436e42f84 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 26 Jan 2026 16:16:23 -0500 Subject: [PATCH 2/3] fix(cliproxy): improve skip-local-auth edge case handling - Validate authToken with trim() to reject whitespace-only values - Show broken model warning for both remote and local modes - Add context-aware messaging for remote proxy users - Add comprehensive test coverage for authToken edge cases: - Whitespace-only strings - Null values - Tabs/newlines - Tokens with leading/trailing whitespace Co-authored-by: Shun Kakinoki --- src/cliproxy/cliproxy-executor.ts | 34 +++--- tests/unit/cliproxy/skip-local-auth.test.ts | 119 ++++++++++++++++---- 2 files changed, 113 insertions(+), 40 deletions(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index c74453f5..96b1e14b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -500,7 +500,9 @@ export async function execClaudeWithCLIProxy( // 3. Ensure OAuth completed (if provider requires it) // Skip local OAuth check when using remote proxy with auth token // The remote proxy has its own OAuth sessions and handles authentication - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + // Note: Trim authToken to reject whitespace-only values + const remoteAuthToken = proxyConfig.authToken?.trim(); + const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; if (skipLocalAuth) { log(`Using remote proxy authentication (skipping local OAuth)`); } @@ -585,21 +587,23 @@ export async function execClaudeWithCLIProxy( } // 5. Check for known broken models and warn user - // Skip for remote proxy - model selection is on the remote server - if (!skipLocalAuth) { - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); - } - console.error(` Run "ccs ${provider} --config" to change model.`); - console.error(''); + // Show warning for both local and remote modes - user should be aware of model issues + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); } + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } + console.error(''); } // 6. Ensure user settings file exists (creates from defaults if not) diff --git a/tests/unit/cliproxy/skip-local-auth.test.ts b/tests/unit/cliproxy/skip-local-auth.test.ts index e840e032..b20ff061 100644 --- a/tests/unit/cliproxy/skip-local-auth.test.ts +++ b/tests/unit/cliproxy/skip-local-auth.test.ts @@ -3,54 +3,116 @@ * * When --proxy-host and --proxy-auth-token are provided together, the system should * skip local OAuth checks because the remote proxy handles authentication. + * + * Implementation uses: const remoteAuthToken = proxyConfig.authToken?.trim(); + * const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; */ import { describe, it, expect } from 'bun:test'; +/** + * Helper to compute skipLocalAuth exactly as the implementation does + * Mirrors logic from cliproxy-executor.ts lines 503-505 + */ +function computeSkipLocalAuth( + useRemoteProxy: boolean, + proxyConfig: { authToken?: string | null } +): boolean { + const remoteAuthToken = proxyConfig.authToken?.trim(); + return useRemoteProxy && !!remoteAuthToken; +} + describe('skip-local-auth logic', () => { describe('skipLocalAuth flag determination', () => { it('should skip local auth when both useRemoteProxy and authToken are truthy', () => { const useRemoteProxy = true; const proxyConfig = { authToken: 'test-token-123' }; - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); - expect(skipLocalAuth).toBeTruthy(); + expect(skipLocalAuth).toBe(true); }); it('should NOT skip local auth when useRemoteProxy is false', () => { const useRemoteProxy = false; const proxyConfig = { authToken: 'test-token-123' }; - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); - expect(skipLocalAuth).toBeFalsy(); + expect(skipLocalAuth).toBe(false); }); it('should NOT skip local auth when authToken is undefined', () => { const useRemoteProxy = true; const proxyConfig = { authToken: undefined }; - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); - expect(skipLocalAuth).toBeFalsy(); + expect(skipLocalAuth).toBe(false); }); it('should NOT skip local auth when authToken is empty string', () => { const useRemoteProxy = true; const proxyConfig = { authToken: '' }; - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); - expect(skipLocalAuth).toBeFalsy(); + expect(skipLocalAuth).toBe(false); }); it('should NOT skip local auth when both are falsy', () => { const useRemoteProxy = false; const proxyConfig = { authToken: undefined }; - const skipLocalAuth = useRemoteProxy && proxyConfig.authToken; + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); - expect(skipLocalAuth).toBeFalsy(); + expect(skipLocalAuth).toBe(false); + }); + }); + + describe('authToken edge cases', () => { + it('should NOT skip local auth when authToken is whitespace-only', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: ' ' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is tabs and newlines', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: '\t\n\r' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is null', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: null }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should skip local auth when authToken has leading/trailing whitespace but valid content', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: ' valid-token-123 ' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(true); + }); + + it('should skip local auth when authToken contains special characters', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(true); }); }); @@ -152,26 +214,33 @@ describe('skip-local-auth logic', () => { }); }); - describe('broken model warning bypass', () => { - it('should skip broken model warning when using remote proxy with auth', () => { + describe('broken model warning behavior', () => { + it('should show broken model warning in BOTH remote and local modes', () => { + // Updated behavior: warnings always shown (with different messaging for remote) + // Remote users need to know about broken models too + const currentModel = 'some-broken-model'; + const isModelBroken = true; + + // Warning should show regardless of skipLocalAuth + const shouldWarnRemote = currentModel && isModelBroken; // skipLocalAuth=true + const shouldWarnLocal = currentModel && isModelBroken; // skipLocalAuth=false + + expect(shouldWarnRemote).toBe(true); + expect(shouldWarnLocal).toBe(true); + }); + + it('should show different message for remote vs local mode', () => { const skipLocalAuth = true; const currentModel = 'some-broken-model'; const isModelBroken = true; - // Logic: only warn if NOT skipping local auth - const shouldWarn = !skipLocalAuth && currentModel && isModelBroken; + // When remote: "Note: Model may be overridden by remote proxy configuration." + // When local: "Run ccs --config to change model." + const remoteMessage = skipLocalAuth + ? 'Note: Model may be overridden by remote proxy configuration.' + : 'Run "ccs provider --config" to change model.'; - expect(shouldWarn).toBe(false); - }); - - it('should show broken model warning when using local mode', () => { - const skipLocalAuth = false; - const currentModel = 'some-broken-model'; - const isModelBroken = true; - - const shouldWarn = !skipLocalAuth && currentModel && isModelBroken; - - expect(shouldWarn).toBe(true); + expect(remoteMessage).toContain('remote proxy'); }); }); From 838cd1d460de68acb571bb44bc12f91bd0636ff7 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 26 Jan 2026 16:19:56 -0500 Subject: [PATCH 3/3] fix(test): use correct provider name 'ghcp' instead of 'copilot' --- tests/unit/cliproxy/skip-local-auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cliproxy/skip-local-auth.test.ts b/tests/unit/cliproxy/skip-local-auth.test.ts index b20ff061..f507f40f 100644 --- a/tests/unit/cliproxy/skip-local-auth.test.ts +++ b/tests/unit/cliproxy/skip-local-auth.test.ts @@ -185,7 +185,7 @@ describe('skip-local-auth logic', () => { }); it('should not run preflight for non-agy providers regardless of mode', () => { - const providers = ['gemini', 'codex', 'copilot', 'kiro']; + const providers = ['gemini', 'codex', 'ghcp', 'kiro']; for (const provider of providers) { const shouldRunPreflight = provider === 'agy' && !false;