mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user