fix(cliproxy): explain headless Codex OAuth recovery

Closes #1213
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-11 14:25:34 -04:00
committed by GitHub
parent d86032ed0a
commit 29da75c0d4
8 changed files with 313 additions and 21 deletions
@@ -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 <USER>@<HOST>');
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(
+26
View File
@@ -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<AccountInfo | null> {
// 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,
}
);
}
@@ -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} <USER>@<HOST>`
: 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;
}
+34 -9
View File
@@ -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);
}
});
@@ -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 <USER>@<HOST>',
});
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([
{
@@ -733,7 +733,11 @@ export function AddAccountDialog({
)}
{/* Persist error visibility outside auth-only UI states */}
{errorMessage && <p className="text-xs text-center text-destructive">{errorMessage}</p>}
{errorMessage && (
<p className="text-xs text-center text-destructive whitespace-pre-line">
{errorMessage}
</p>
)}
{/* Kiro import loading */}
{kiroImportMutation.isPending && (
+17 -11
View File
@@ -69,6 +69,22 @@ async function parseResponseBody(response: Response): Promise<Record<string, unk
}
}
function buildAuthErrorMessage(data: Record<string, unknown>, 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);
}
@@ -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 <USER>@<HOST>',
'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 <USER>@<HOST>');
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<Response>();
let startCount = 0;