From 29da75c0d42ed473029d1222c7fe4e5e655f5b03 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 11 May 2026 14:25:34 -0400 Subject: [PATCH 01/38] fix(cliproxy): explain headless Codex OAuth recovery Closes #1213 --- .../oauth-handler-paste-callback.test.ts | 32 +++++ src/cliproxy/auth/oauth-handler.ts | 26 +++++ .../auth/oauth-start-failure-guidance.ts | 110 ++++++++++++++++++ src/web-server/routes/cliproxy-auth-routes.ts | 43 +++++-- ...iproxy-auth-routes-manual-callback.test.ts | 46 ++++++++ .../components/account/add-account-dialog.tsx | 6 +- ui/src/hooks/use-cliproxy-auth-flow.ts | 28 +++-- .../hooks/use-cliproxy-auth-flow.test.tsx | 43 +++++++ 8 files changed, 313 insertions(+), 21 deletions(-) create mode 100644 src/cliproxy/auth/oauth-start-failure-guidance.ts diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 33f62b38..82b83ca0 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -14,6 +14,13 @@ const remoteTarget: ProxyTarget = { isRemote: true, }; +const localTarget: ProxyTarget = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, +}; + afterEach(() => { restoreFetch(); }); @@ -80,6 +87,31 @@ describe('requestPasteCallbackStart', () => { }); }); +describe('OAuth start failure guidance', () => { + it('explains Codex paste-callback recovery in headless local mode', async () => { + const { buildOAuthStartFailureGuidance, formatOAuthStartFailureForCli } = await import( + `../oauth-start-failure-guidance?codex-local-guidance=${Date.now()}` + ); + + const guidance = buildOAuthStartFailureGuidance('codex', { + target: localTarget, + startPath: '/v0/management/codex-auth-url?is_webui=true', + cause: new Error('fetch failed'), + addAccount: true, + }); + const cliOutput = formatOAuthStartFailureForCli(guidance).join('\n'); + + expect(guidance.message).toContain('OpenAI Codex OAuth could not start'); + expect(guidance.endpoint).toBe( + 'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true' + ); + expect(cliOutput).toContain('ccs cliproxy start'); + expect(cliOutput).toContain('ccs codex --auth --add --paste-callback'); + expect(cliOutput).toContain('ssh -L 1455:localhost:1455 @'); + expect(cliOutput).toContain('ccs codex --auth --add --port-forward'); + }); +}); + describe('Gemini Plus OAuth credential diagnostics', () => { it('fails fast when Gemini uses Plus without OAuth client env', async () => { const { getGeminiPlusOAuthCredentialError } = await import( diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index ac58e0bc..bde903b3 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -58,6 +58,10 @@ import { import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; +import { + buildOAuthStartFailureGuidance, + formatOAuthStartFailureForCli, +} from './oauth-start-failure-guidance'; import { getProxyTarget, buildProxyUrl, @@ -647,6 +651,7 @@ async function handlePasteCallbackMode( options?: { kiroMethod?: OAuthOptions['kiroMethod']; gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl']; + add?: boolean; } ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) @@ -661,13 +666,33 @@ async function handlePasteCallbackMode( // Request auth URL from CLIProxyAPI management endpoints when the selected // provider/method supports the manual start-url contract. let startData: PasteCallbackStartData; + let startPath = getPasteCallbackStartPath(provider, { + kiroMethod: options?.kiroMethod, + }); + const normalizedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined; + if (startPath && normalizedGitLabBaseUrl) { + startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`; + } try { startData = await requestPasteCallbackStart(provider, target, { kiroMethod: options?.kiroMethod, + gitlabBaseUrl: options?.gitlabBaseUrl, }); } catch (error) { const startError = (error as Error).message; console.log(fail('Failed to start OAuth flow')); + if (startPath) { + const guidance = buildOAuthStartFailureGuidance(provider, { + target, + startPath, + cause: error, + addAccount: options?.add, + }); + for (const line of formatOAuthStartFailureForCli(guidance)) { + console.log(` ${line}`); + } + } warnPossible403Ban(provider, startError); return null; } @@ -1109,6 +1134,7 @@ export async function triggerOAuth( { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, + add, } ); } diff --git a/src/cliproxy/auth/oauth-start-failure-guidance.ts b/src/cliproxy/auth/oauth-start-failure-guidance.ts new file mode 100644 index 00000000..8d998efd --- /dev/null +++ b/src/cliproxy/auth/oauth-start-failure-guidance.ts @@ -0,0 +1,110 @@ +import type { CLIProxyProvider } from '../types'; +import { getOAuthCallbackPort, getProviderDisplayName } from '../provider-capabilities'; +import { buildProxyUrl, type ProxyTarget } from '../proxy/proxy-target-resolver'; + +export interface OAuthStartFailureGuidance { + error: 'cliproxy_oauth_start_failed'; + provider: CLIProxyProvider; + message: string; + details: string; + hints: string[]; + endpoint: string | null; + retryCommand: string; + portForwardCommand?: string; +} + +interface GuidanceOptions { + target: ProxyTarget; + startPath?: string | null; + cause?: unknown; + addAccount?: boolean; +} + +function getCauseMessage(cause: unknown): string | null { + if (cause instanceof Error && cause.message.trim()) { + return cause.message.trim(); + } + if (typeof cause === 'string' && cause.trim()) { + return cause.trim(); + } + return null; +} + +function buildAuthCommand( + provider: CLIProxyProvider, + flag: '--paste-callback' | '--port-forward', + addAccount?: boolean +): string { + const parts = ['ccs', provider, '--auth']; + if (addAccount) { + parts.push('--add'); + } + parts.push(flag); + return parts.join(' '); +} + +export function buildOAuthStartFailureGuidance( + provider: CLIProxyProvider, + options: GuidanceOptions +): OAuthStartFailureGuidance { + const { target, startPath, cause, addAccount } = options; + const displayName = getProviderDisplayName(provider); + const endpoint = startPath ? buildProxyUrl(target, startPath) : null; + const causeMessage = getCauseMessage(cause); + const retryCommand = buildAuthCommand(provider, '--paste-callback', addAccount); + const portForwardRetryCommand = buildAuthCommand(provider, '--port-forward', addAccount); + const callbackPort = getOAuthCallbackPort(provider); + const portForwardCommand = callbackPort + ? `ssh -L ${callbackPort}:localhost:${callbackPort} @` + : undefined; + + const targetDescription = target.isRemote + ? `remote CLIProxy management API at ${target.protocol}://${target.host}:${target.port}` + : `local CLIProxy management API at ${target.protocol}://${target.host}:${target.port}`; + + const message = `${displayName} OAuth could not start through the ${targetDescription}.`; + const details = [ + endpoint ? `Endpoint: ${endpoint}` : null, + causeMessage ? `Cause: ${causeMessage}` : null, + ] + .filter(Boolean) + .join(' '); + + const hints = target.isRemote + ? [ + 'Verify the remote CLIProxy server is running and reachable from this machine.', + 'Check cliproxy_server.remote.management_key or auth_token in CCS config.', + `After fixing the remote proxy, retry: ${retryCommand}`, + ] + : [ + 'Start local CLIProxy first: ccs cliproxy start', + `Then retry paste-callback mode: ${retryCommand}`, + ...(portForwardCommand + ? [ + `For SSH/VPS auth, open a tunnel from your local machine: ${portForwardCommand}`, + `Then run inside that SSH session: ${portForwardRetryCommand}`, + ] + : []), + ]; + + return { + error: 'cliproxy_oauth_start_failed', + provider, + message, + details, + hints, + endpoint, + retryCommand, + portForwardCommand, + }; +} + +export function formatOAuthStartFailureForCli(guidance: OAuthStartFailureGuidance): string[] { + const lines = [guidance.message]; + if (guidance.details) { + lines.push(guidance.details); + } + lines.push('Next steps:'); + lines.push(...guidance.hints.map((hint) => ` - ${hint}`)); + return lines; +} diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 90834888..72920473 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -74,6 +74,7 @@ import { getPlusOAuthCredentialError, getPlusAuthUrlCredentialError, } from '../../cliproxy/auth/oauth-handler'; +import { buildOAuthStartFailureGuidance } from '../../cliproxy/auth/oauth-start-failure-guidance'; import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager'; const router = Router(); @@ -1065,6 +1066,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + let startPath: string | null = null; try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; @@ -1077,20 +1079,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise provider === 'gitlab' && gitlabBaseUrl ? `&base_url=${encodeURIComponent(gitlabBaseUrl)}` : ''; + startPath = `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix - const response = await fetch( - buildProxyUrl( - target, - `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}` - ), - { headers: buildManagementHeaders(target) } - ); + const response = await fetch(buildProxyUrl(target, startPath), { + headers: buildManagementHeaders(target), + }); if (!response.ok) { const error = await response.text(); - res.status(response.status).json({ error: error || 'Failed to start OAuth' }); + const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, { + target, + startPath, + cause: error || `HTTP ${response.status}`, + }); + res.status(response.status).json(guidance); return; } @@ -1144,7 +1148,28 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method: data.method || null, }); } catch (error) { - respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); + if (error instanceof SyntaxError) { + console.error( + `[cliproxy-auth-routes] Invalid OAuth start response for provider=${provider}: ${error.message}` + ); + res.status(502).json({ + error: 'cliproxy_oauth_start_invalid_response', + provider, + message: 'CLIProxyAPI returned an invalid OAuth start response.', + details: error.message, + }); + return; + } + + const authUrlProvider = + CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, { + target, + startPath: startPath ?? `/v0/management/${authUrlProvider}-auth-url?is_webui=true`, + cause: error, + }); + console.error(`[cliproxy-auth-routes] ${guidance.message} ${guidance.details}`); + res.status(503).json(guidance); } }); diff --git a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts index 89b83904..dfd476ab 100644 --- a/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes-manual-callback.test.ts @@ -207,6 +207,52 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true'); }); + it('returns actionable Codex guidance when manual OAuth start cannot reach CLIProxy', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + status: 503, + response: 'connect ECONNREFUSED 127.0.0.1:8317', + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + + expect(startResponse.status).toBe(503); + expect(startResponse.body).toMatchObject({ + error: 'cliproxy_oauth_start_failed', + provider: 'codex', + message: expect.stringContaining('OpenAI Codex OAuth could not start'), + endpoint: 'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true', + retryCommand: 'ccs codex --auth --paste-callback', + portForwardCommand: 'ssh -L 1455:localhost:1455 @', + }); + expect( + (startResponse.body as { hints?: string[] }).hints?.some((hint) => + hint.includes('ccs codex --auth --port-forward') + ) + ).toBe(true); + }); + + it('does not label malformed OAuth start responses as proxy recovery guidance', async () => { + mockFetch([ + { + url: /\/v0\/management\/codex-auth-url\?is_webui=true$/, + response: 'not-json', + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {}); + + expect(startResponse.status).toBe(502); + expect(startResponse.body).toMatchObject({ + error: 'cliproxy_oauth_start_invalid_response', + provider: 'codex', + message: 'CLIProxyAPI returned an invalid OAuth start response.', + }); + expect((startResponse.body as { hints?: string[] }).hints).toBeUndefined(); + }); + it('returns wait after callback submission when the local token is not yet available', async () => { mockFetch([ { diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 403ba88f..feb43cfe 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -733,7 +733,11 @@ export function AddAccountDialog({ )} {/* Persist error visibility outside auth-only UI states */} - {errorMessage &&

{errorMessage}

} + {errorMessage && ( +

+ {errorMessage} +

+ )} {/* Kiro import loading */} {kiroImportMutation.isPending && ( diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index bba9788a..509abdf9 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -69,6 +69,22 @@ async function parseResponseBody(response: Response): Promise, fallback: string): string { + const message = + typeof data.message === 'string' + ? data.message + : typeof data.error === 'string' + ? data.error + : fallback; + const hints = Array.isArray(data.hints) + ? data.hints.filter( + (hint): hint is string => typeof hint === 'string' && hint.trim().length > 0 + ) + : []; + + return hints.length > 0 ? [message, ...hints].join('\n') : message; +} + /** Initial state for auth flow - extracted for DRY */ const INITIAL_STATE: AuthFlowState = { provider: null, @@ -371,17 +387,7 @@ export function useCliproxyAuthFlow() { const success = data.success === true; if (!response.ok || !success) { - // For Plus OAuth credential errors the server sends a human-readable - // explanation in `data.message`; prefer it over the machine error code. - const isPlusCredentialError = - data.error === 'plus_oauth_credentials_missing' || - data.error === 'plus_oauth_url_missing_client_id'; - const errorMsg = - isPlusCredentialError && typeof data.message === 'string' - ? data.message - : typeof data.error === 'string' - ? data.error - : t('toasts.providerStartOAuthFailed'); + const errorMsg = buildAuthErrorMessage(data, t('toasts.providerStartOAuthFailed')); throw new Error(errorMsg); } diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index c7e6e9e6..5827b444 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -86,6 +86,49 @@ describe('useCliproxyAuthFlow', () => { ); }); + it('surfaces manual OAuth start guidance from the dashboard API', async () => { + const guidance = [ + 'Start local CLIProxy first: ccs cliproxy start', + 'For SSH/VPS auth, open a tunnel from your local machine: ssh -L 1455:localhost:1455 @', + 'Then run inside that SSH session: ccs codex --auth --port-forward', + ]; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/codex/start-url')) { + return Promise.resolve( + createJsonResponse( + { + error: 'cliproxy_oauth_start_failed', + message: + 'OpenAI Codex OAuth could not start because CCS could not reach the local CLIProxy management API.', + hints: guidance, + }, + 503 + ) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('codex', { startEndpoint: 'start-url' }); + }); + + expect(result.current.isAuthenticating).toBe(false); + expect(result.current.error).toContain('OpenAI Codex OAuth could not start'); + expect(result.current.error).toContain('ccs cliproxy start'); + expect(result.current.error).toContain('ssh -L 1455:localhost:1455 @'); + expect(result.current.error).not.toContain('cliproxy_oauth_start_failed'); + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('ccs cliproxy start')); + }); + it('ignores stale poll completions from a superseded auth attempt', async () => { const firstPoll = createDeferred(); let startCount = 0; From 56d5493a61bdc93dc9e78d50f8a96023fc159dc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 18:29:03 +0000 Subject: [PATCH 02/38] chore(release): 7.78.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6cfe1e0f..d9249e65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.78.0", + "version": "7.78.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 225401ceca433705d11c119b3373913c95129c56 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 11 May 2026 20:43:26 -0400 Subject: [PATCH 03/38] fix(ui): render codex config as exact text (#1215) --- .../raw-json-settings-editor-panel.tsx | 3 + ui/src/components/shared/code-editor.tsx | 93 ++++++++++++------- ui/src/pages/codex.tsx | 1 + .../unit/components/ui/code-editor.test.tsx | 28 ++++++ 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 62dd91f6..fce059e5 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -23,6 +23,7 @@ interface RawConfigEditorPanelProps { onRefresh: () => Promise | void; onDiscard?: () => void; language?: 'json' | 'yaml' | 'toml'; + plainText?: boolean; loadingLabel?: string; parseWarningLabel?: string; ownershipNotice?: ReactNode; @@ -44,6 +45,7 @@ export function RawConfigEditorPanel({ onRefresh, onDiscard, language = 'json', + plainText = false, loadingLabel = 'Loading settings.json...', parseWarningLabel = 'Parse warning', ownershipNotice, @@ -128,6 +130,7 @@ export function RawConfigEditorPanel({ onChange={onChange} language={language} readonly={readOnly} + plainText={plainText} minHeight="100%" heightMode="fill-parent" /> diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 311e0371..11860fbc 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -24,6 +24,7 @@ interface CodeEditorProps { onChange: (value: string) => void; language?: 'json' | 'yaml' | 'toml'; readonly?: boolean; + plainText?: boolean; className?: string; minHeight?: string; heightMode?: 'content' | 'fill-parent'; @@ -95,6 +96,7 @@ export function CodeEditor({ onChange, language = 'json', readonly = false, + plainText = false, className, minHeight = '300px', heightMode = 'content', @@ -211,40 +213,67 @@ export function CodeEditor({ className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')} data-slot={isFillParent ? 'code-editor-viewport' : undefined} > - {} : onChange} - highlight={highlightCode} - key={isDark ? 'dark-editor' : 'light-editor'} - padding={12} - disabled={readonly} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textareaClassName={cn( - 'focus:outline-none font-mono text-sm', - readonly && 'cursor-not-allowed' - )} - preClassName="font-mono text-sm" - style={{ - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: '0.875rem', - minHeight, - }} - /> + {plainText ? ( +