mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
fix(kiro): harden idc and callback auth paths
- keep headless paste routing aligned with the selected Kiro auth method - validate local callback replay targets and add prompt cancellation safeguards - wire IDC params through the dashboard start route and support equals-form CLI flags
This commit is contained in:
@@ -343,6 +343,17 @@ function buildOAuthArgs(
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usesKiroLocalCallbackReplay(
|
||||||
|
method: OAuthOptions['kiroMethod'],
|
||||||
|
idcFlow: OAuthOptions['kiroIDCFlow']
|
||||||
|
): boolean {
|
||||||
|
const normalizedMethod = normalizeKiroAuthMethod(method);
|
||||||
|
if (normalizedMethod === 'aws-authcode') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalizedMethod === 'idc' && normalizeKiroIDCFlow(idcFlow) === 'authcode';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle paste-callback mode: show auth URL, prompt for callback paste
|
* Handle paste-callback mode: show auth URL, prompt for callback paste
|
||||||
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
|
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
|
||||||
@@ -581,10 +592,7 @@ export async function triggerOAuth(
|
|||||||
provider === 'kiro'
|
provider === 'kiro'
|
||||||
? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
|
? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
|
||||||
: callbackPort === null;
|
: callbackPort === null;
|
||||||
const useKiroLocalPasteCallback =
|
let selectedPasteCallback = options.pasteCallback === true;
|
||||||
options.pasteCallback === true && provider === 'kiro' && !isDeviceCodeFlow;
|
|
||||||
const useKiroDirectCliFlow =
|
|
||||||
provider === 'kiro' && (isDeviceCodeFlow || useKiroLocalPasteCallback);
|
|
||||||
|
|
||||||
if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) {
|
if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) {
|
||||||
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
|
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
|
||||||
@@ -594,34 +602,25 @@ export async function triggerOAuth(
|
|||||||
|
|
||||||
// Interactive mode selection for headless environments
|
// Interactive mode selection for headless environments
|
||||||
// Skip if explicit mode flag provided or device code flow (no callback needed)
|
// Skip if explicit mode flag provided or device code flow (no callback needed)
|
||||||
if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) {
|
if (headless && !selectedPasteCallback && !options.portForward && !isDeviceCodeFlow) {
|
||||||
// Non-interactive environment (piped input) - default to paste mode
|
// Non-interactive environment (piped input) - default to paste mode
|
||||||
if (!process.stdin.isTTY) {
|
if (!process.stdin.isTTY) {
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
selectedPasteCallback = true;
|
||||||
return handlePasteCallbackMode(
|
} else {
|
||||||
provider,
|
const mode = await promptOAuthModeChoice(callbackPort);
|
||||||
oauthConfig,
|
if (mode === 'paste') {
|
||||||
verbose,
|
selectedPasteCallback = true;
|
||||||
tokenDir,
|
}
|
||||||
nickname,
|
|
||||||
existingNameMatch?.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const mode = await promptOAuthModeChoice(callbackPort);
|
|
||||||
if (mode === 'paste') {
|
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
|
||||||
return handlePasteCallbackMode(
|
|
||||||
provider,
|
|
||||||
oauthConfig,
|
|
||||||
verbose,
|
|
||||||
tokenDir,
|
|
||||||
nickname,
|
|
||||||
existingNameMatch?.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// mode === 'forward' continues to existing port-forwarding flow below
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useSelectedKiroLocalPasteCallback =
|
||||||
|
selectedPasteCallback &&
|
||||||
|
provider === 'kiro' &&
|
||||||
|
usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow);
|
||||||
|
const useSelectedKiroDirectCliFlow =
|
||||||
|
provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback);
|
||||||
|
|
||||||
if (existingAccounts.length > 0 && !add) {
|
if (existingAccounts.length > 0 && !add) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(
|
console.log(
|
||||||
@@ -636,7 +635,7 @@ export async function triggerOAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.pasteCallback && !useKiroDirectCliFlow) {
|
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
return handlePasteCallbackMode(
|
return handlePasteCallbackMode(
|
||||||
provider,
|
provider,
|
||||||
@@ -671,7 +670,7 @@ export async function triggerOAuth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const processHeadless = options.pasteCallback && provider === 'kiro' ? true : headless;
|
const processHeadless = selectedPasteCallback && provider === 'kiro' ? true : headless;
|
||||||
let args: string[];
|
let args: string[];
|
||||||
try {
|
try {
|
||||||
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
|
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
|
||||||
@@ -694,7 +693,7 @@ export async function triggerOAuth(
|
|||||||
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
|
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
|
||||||
|
|
||||||
// Show headless instructions (only for authorization code flows)
|
// Show headless instructions (only for authorization code flows)
|
||||||
if (useKiroLocalPasteCallback) {
|
if (useSelectedKiroLocalPasteCallback) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info('Paste-callback mode enabled for Kiro CLI auth.'));
|
console.log(info('Paste-callback mode enabled for Kiro CLI auth.'));
|
||||||
console.log(
|
console.log(
|
||||||
@@ -728,7 +727,7 @@ export async function triggerOAuth(
|
|||||||
expectedAccountId: existingNameMatch?.id,
|
expectedAccountId: existingNameMatch?.id,
|
||||||
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
|
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
|
||||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||||
manualCallback: useKiroLocalPasteCallback,
|
manualCallback: useSelectedKiroLocalPasteCallback,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ interface ProcessState {
|
|||||||
userCode: string | null;
|
userCode: string | null;
|
||||||
kiroMethodSelectionHandled: boolean;
|
kiroMethodSelectionHandled: boolean;
|
||||||
manualCallbackPrompted: boolean;
|
manualCallbackPrompted: boolean;
|
||||||
|
cancelManualCallbackPrompt: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +116,86 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut
|
|||||||
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
|
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptManualCallbackUrl(displayName: string): Promise<string | null> {
|
export function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === '127.0.0.1' ||
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized === '::1' ||
|
||||||
|
normalized === '0:0:0:0:0:0:0:1'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpectedLocalCallback(authUrl: string): {
|
||||||
|
origin: string;
|
||||||
|
pathname: string;
|
||||||
|
state: string | null;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const parsedAuthUrl = new URL(authUrl);
|
||||||
|
const redirectUriRaw = parsedAuthUrl.searchParams.get('redirect_uri');
|
||||||
|
if (!redirectUriRaw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = new URL(redirectUriRaw);
|
||||||
|
if (!isLoopbackHost(redirectUri.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
origin: redirectUri.origin,
|
||||||
|
pathname: redirectUri.pathname,
|
||||||
|
state: parsedAuthUrl.searchParams.get('state'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): string | null {
|
||||||
|
let parsedCallback: URL;
|
||||||
|
try {
|
||||||
|
parsedCallback = new URL(callbackUrl);
|
||||||
|
} catch {
|
||||||
|
return 'Invalid callback URL format';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedCallback.searchParams.get('code')) {
|
||||||
|
return 'Invalid callback URL: missing code parameter';
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCallback = getExpectedLocalCallback(authUrl);
|
||||||
|
if (!expectedCallback) {
|
||||||
|
return 'Unable to determine the expected local callback target';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackHost(parsedCallback.hostname)) {
|
||||||
|
return 'Callback URL must target the local OAuth callback server';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsedCallback.origin !== expectedCallback.origin ||
|
||||||
|
parsedCallback.pathname !== expectedCallback.pathname
|
||||||
|
) {
|
||||||
|
return 'Callback URL does not match the expected local OAuth callback target';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedCallback.state) {
|
||||||
|
const callbackState = parsedCallback.searchParams.get('state');
|
||||||
|
if (callbackState !== expectedCallback.state) {
|
||||||
|
return 'Callback URL state does not match the active OAuth session';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptManualCallbackUrl(
|
||||||
|
displayName: string,
|
||||||
|
state: ProcessState,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<string | null> {
|
||||||
const readline = await import('readline');
|
const readline = await import('readline');
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
@@ -124,53 +204,71 @@ async function promptManualCallbackUrl(displayName: string): Promise<string | nu
|
|||||||
|
|
||||||
return new Promise<string | null>((resolve) => {
|
return new Promise<string | null>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const finish = (value: string | null) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
state.cancelManualCallbackPrompt = null;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
state.cancelManualCallbackPrompt = () => {
|
||||||
|
if (!settled) {
|
||||||
|
rl.close();
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
rl.on('close', () => {
|
rl.on('close', () => {
|
||||||
if (!settled) {
|
finish(null);
|
||||||
settled = true;
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info(`${displayName} is waiting for the OAuth callback.`));
|
console.log(info(`${displayName} is waiting for the OAuth callback.`));
|
||||||
console.log('Paste the full callback URL after you finish the login in your browser.');
|
console.log('Paste the full callback URL after you finish the login in your browser.');
|
||||||
rl.question('> ', (answer) => {
|
rl.question('> ', (answer) => {
|
||||||
settled = true;
|
|
||||||
rl.close();
|
rl.close();
|
||||||
resolve(answer.trim() || null);
|
finish(answer.trim() || null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
console.log('');
|
||||||
|
console.log(fail('Timed out waiting for callback URL'));
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replayManualCallback(
|
async function replayManualCallback(
|
||||||
oauthConfig: ProviderOAuthConfig,
|
oauthConfig: ProviderOAuthConfig,
|
||||||
authProcess: ChildProcess,
|
authProcess: ChildProcess,
|
||||||
output: string,
|
authUrl: string,
|
||||||
verbose: boolean
|
verbose: boolean,
|
||||||
|
state: ProcessState,
|
||||||
|
timeoutMs: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!output.includes('http://') && !output.includes('https://')) {
|
if (!authUrl.includes('http://') && !authUrl.includes('https://')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName);
|
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs);
|
||||||
if (!callbackUrl) {
|
if (!callbackUrl) {
|
||||||
console.log(info('Cancelled'));
|
console.log(info('Cancelled'));
|
||||||
killWithEscalation(authProcess);
|
killWithEscalation(authProcess);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: URL;
|
const validationError = validateManualCallbackUrl(callbackUrl, authUrl);
|
||||||
try {
|
if (validationError) {
|
||||||
parsed = new URL(callbackUrl);
|
console.log(fail(validationError));
|
||||||
} catch {
|
|
||||||
console.log(fail('Invalid callback URL format'));
|
|
||||||
killWithEscalation(authProcess);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed.searchParams.get('code')) {
|
|
||||||
console.log(fail('Invalid callback URL: missing code parameter'));
|
|
||||||
killWithEscalation(authProcess);
|
killWithEscalation(authProcess);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -302,7 +400,14 @@ async function handleStdout(
|
|||||||
|
|
||||||
if (options.manualCallback && !state.manualCallbackPrompted) {
|
if (options.manualCallback && !state.manualCallbackPrompted) {
|
||||||
state.manualCallbackPrompted = true;
|
state.manualCallbackPrompted = true;
|
||||||
await replayManualCallback(options.oauthConfig, authProcess, urlMatch[0], options.verbose);
|
await replayManualCallback(
|
||||||
|
options.oauthConfig,
|
||||||
|
authProcess,
|
||||||
|
urlMatch[0],
|
||||||
|
options.verbose,
|
||||||
|
state,
|
||||||
|
10 * 60 * 1000
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,6 +640,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
userCode: null,
|
userCode: null,
|
||||||
kiroMethodSelectionHandled: false,
|
kiroMethodSelectionHandled: false,
|
||||||
manualCallbackPrompted: false,
|
manualCallbackPrompted: false,
|
||||||
|
cancelManualCallbackPrompt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register session for cancellation support
|
// Register session for cancellation support
|
||||||
@@ -570,13 +676,27 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
await handleStdout(data.toString(), state, options, authProcess, log);
|
await handleStdout(data.toString(), state, options, authProcess, log);
|
||||||
});
|
});
|
||||||
|
|
||||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
authProcess.stderr?.on('data', async (data: Buffer) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
state.stderrData += output;
|
state.stderrData += output;
|
||||||
log(`stderr: ${output.trim()}`);
|
log(`stderr: ${output.trim()}`);
|
||||||
if (headless && !state.urlDisplayed) {
|
if (headless && !state.urlDisplayed) {
|
||||||
displayUrlFromStderr(output, state, oauthConfig);
|
displayUrlFromStderr(output, state, oauthConfig);
|
||||||
}
|
}
|
||||||
|
if (options.manualCallback && !state.manualCallbackPrompted) {
|
||||||
|
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||||
|
if (urlMatch) {
|
||||||
|
state.manualCallbackPrompted = true;
|
||||||
|
await replayManualCallback(
|
||||||
|
options.oauthConfig,
|
||||||
|
authProcess,
|
||||||
|
urlMatch[0],
|
||||||
|
options.verbose,
|
||||||
|
state,
|
||||||
|
10 * 60 * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show waiting message after delay
|
// Show waiting message after delay
|
||||||
@@ -611,10 +731,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
|
|
||||||
// Timeout handling
|
// Timeout handling
|
||||||
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
|
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
|
||||||
const timeoutMs = headless || isDeviceCodeFlow ? 300000 : 120000;
|
const timeoutMs = options.manualCallback
|
||||||
|
? 10 * 60 * 1000
|
||||||
|
: headless || isDeviceCodeFlow
|
||||||
|
? 300000
|
||||||
|
: 120000;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
// H7: Clear stdin keepalive interval
|
// H7: Clear stdin keepalive interval
|
||||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||||
|
state.cancelManualCallbackPrompt?.();
|
||||||
// H5: Remove signal handlers before killing process
|
// H5: Remove signal handlers before killing process
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
@@ -634,6 +759,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
// H7: Clear stdin keepalive interval
|
// H7: Clear stdin keepalive interval
|
||||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||||
|
state.cancelManualCallbackPrompt?.();
|
||||||
// H5: Remove signal handlers to prevent memory leaks
|
// H5: Remove signal handlers to prevent memory leaks
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
@@ -696,6 +822,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
// H7: Clear stdin keepalive interval
|
// H7: Clear stdin keepalive interval
|
||||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||||
|
state.cancelManualCallbackPrompt?.();
|
||||||
// H5: Remove signal handlers to prevent memory leaks
|
// H5: Remove signal handlers to prevent memory leaks
|
||||||
process.removeListener('SIGINT', cleanup);
|
process.removeListener('SIGINT', cleanup);
|
||||||
process.removeListener('SIGTERM', cleanup);
|
process.removeListener('SIGTERM', cleanup);
|
||||||
|
|||||||
@@ -117,6 +117,34 @@ const DEFAULT_CONFIG: ExecutorConfig = {
|
|||||||
pollInterval: 100,
|
pollInterval: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function readOptionValue(
|
||||||
|
args: string[],
|
||||||
|
flag: string
|
||||||
|
): { present: boolean; value?: string; missingValue: boolean } {
|
||||||
|
const inlinePrefix = `${flag}=`;
|
||||||
|
const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix));
|
||||||
|
if (inlineArg !== undefined) {
|
||||||
|
const value = inlineArg.slice(inlinePrefix.length).trim();
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
value: value.length > 0 ? value : undefined,
|
||||||
|
missingValue: value.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = args.indexOf(flag);
|
||||||
|
if (index === -1) {
|
||||||
|
return { present: false, missingValue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = args[index + 1];
|
||||||
|
if (!next || next.startsWith('-')) {
|
||||||
|
return { present: true, missingValue: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { present: true, value: next.trim(), missingValue: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute Claude CLI with CLIProxy (main entry point)
|
* Execute Claude CLI with CLIProxy (main entry point)
|
||||||
*
|
*
|
||||||
@@ -346,10 +374,10 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// Parse --kiro-auth-method flag
|
// Parse --kiro-auth-method flag
|
||||||
let kiroAuthMethod: KiroAuthMethod | undefined;
|
let kiroAuthMethod: KiroAuthMethod | undefined;
|
||||||
const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method');
|
const kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method');
|
||||||
if (kiroMethodIdx !== -1) {
|
if (kiroMethodValue.present) {
|
||||||
const rawMethod = argsWithoutProxy[kiroMethodIdx + 1];
|
const rawMethod = kiroMethodValue.value;
|
||||||
if (!rawMethod || rawMethod.startsWith('-')) {
|
if (kiroMethodValue.missingValue || !rawMethod) {
|
||||||
console.error(fail('--kiro-auth-method requires a value'));
|
console.error(fail('--kiro-auth-method requires a value'));
|
||||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -366,38 +394,30 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let kiroIDCStartUrl: string | undefined;
|
let kiroIDCStartUrl: string | undefined;
|
||||||
const kiroIDCStartUrlIdx = argsWithoutProxy.indexOf('--kiro-idc-start-url');
|
const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url');
|
||||||
if (
|
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
|
||||||
kiroIDCStartUrlIdx !== -1 &&
|
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
|
||||||
argsWithoutProxy[kiroIDCStartUrlIdx + 1] &&
|
} else if (kiroIDCStartUrlValue.present) {
|
||||||
!argsWithoutProxy[kiroIDCStartUrlIdx + 1].startsWith('-')
|
|
||||||
) {
|
|
||||||
kiroIDCStartUrl = argsWithoutProxy[kiroIDCStartUrlIdx + 1].trim();
|
|
||||||
} else if (kiroIDCStartUrlIdx !== -1) {
|
|
||||||
console.error(fail('--kiro-idc-start-url requires a value'));
|
console.error(fail('--kiro-idc-start-url requires a value'));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let kiroIDCRegion: string | undefined;
|
let kiroIDCRegion: string | undefined;
|
||||||
const kiroIDCRegionIdx = argsWithoutProxy.indexOf('--kiro-idc-region');
|
const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region');
|
||||||
if (
|
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
|
||||||
kiroIDCRegionIdx !== -1 &&
|
kiroIDCRegion = kiroIDCRegionValue.value;
|
||||||
argsWithoutProxy[kiroIDCRegionIdx + 1] &&
|
} else if (kiroIDCRegionValue.present) {
|
||||||
!argsWithoutProxy[kiroIDCRegionIdx + 1].startsWith('-')
|
|
||||||
) {
|
|
||||||
kiroIDCRegion = argsWithoutProxy[kiroIDCRegionIdx + 1].trim();
|
|
||||||
} else if (kiroIDCRegionIdx !== -1) {
|
|
||||||
console.error(fail('--kiro-idc-region requires a value'));
|
console.error(fail('--kiro-idc-region requires a value'));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let kiroIDCFlow: KiroIDCFlow | undefined;
|
let kiroIDCFlow: KiroIDCFlow | undefined;
|
||||||
const kiroIDCFlowIdx = argsWithoutProxy.indexOf('--kiro-idc-flow');
|
const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow');
|
||||||
if (kiroIDCFlowIdx !== -1) {
|
if (kiroIDCFlowValue.present) {
|
||||||
const rawFlow = argsWithoutProxy[kiroIDCFlowIdx + 1];
|
const rawFlow = kiroIDCFlowValue.value;
|
||||||
if (!rawFlow || rawFlow.startsWith('-')) {
|
if (kiroIDCFlowValue.missingValue || !rawFlow) {
|
||||||
console.error(fail('--kiro-idc-flow requires a value'));
|
console.error(fail('--kiro-idc-flow requires a value'));
|
||||||
console.error(' Supported values: authcode, device');
|
console.error(' Supported values: authcode, device');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -1174,6 +1194,10 @@ export async function execClaudeWithCLIProxy(
|
|||||||
];
|
];
|
||||||
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
||||||
if (ccsFlags.includes(arg)) return false;
|
if (ccsFlags.includes(arg)) return false;
|
||||||
|
if (arg.startsWith('--kiro-auth-method=')) return false;
|
||||||
|
if (arg.startsWith('--kiro-idc-start-url=')) return false;
|
||||||
|
if (arg.startsWith('--kiro-idc-region=')) return false;
|
||||||
|
if (arg.startsWith('--kiro-idc-flow=')) return false;
|
||||||
if (arg.startsWith('--thinking=')) return false;
|
if (arg.startsWith('--thinking=')) return false;
|
||||||
if (arg.startsWith('--effort=')) return false;
|
if (arg.startsWith('--effort=')) return false;
|
||||||
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
|
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ import {
|
|||||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||||
isKiroAuthMethod,
|
isKiroAuthMethod,
|
||||||
|
isKiroIDCFlow,
|
||||||
isKiroDeviceCodeMethod,
|
isKiroDeviceCodeMethod,
|
||||||
|
KiroIDCFlow,
|
||||||
KiroAuthMethod,
|
KiroAuthMethod,
|
||||||
|
normalizeKiroIDCFlow,
|
||||||
normalizeKiroAuthMethod,
|
normalizeKiroAuthMethod,
|
||||||
toKiroManagementMethod,
|
toKiroManagementMethod,
|
||||||
} from '../../cliproxy/auth/auth-types';
|
} from '../../cliproxy/auth/auth-types';
|
||||||
@@ -256,6 +259,43 @@ function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boole
|
|||||||
return { method: normalizeKiroAuthMethod(normalized), invalid: false };
|
return { method: normalizeKiroAuthMethod(normalized), invalid: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean } {
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
return { flow: normalizeKiroIDCFlow(), invalid: false };
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return { flow: normalizeKiroIDCFlow(), invalid: true };
|
||||||
|
}
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!isKiroIDCFlow(normalized)) {
|
||||||
|
return { flow: normalizeKiroIDCFlow(), invalid: true };
|
||||||
|
}
|
||||||
|
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKiroStartIDCValidationError(options: {
|
||||||
|
kiroMethod: KiroAuthMethod;
|
||||||
|
kiroIDCStartUrl?: string;
|
||||||
|
invalidKiroIDCFlow?: boolean;
|
||||||
|
}): { error: string; code: string } | null {
|
||||||
|
if (options.kiroMethod !== 'idc') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options.invalidKiroIDCFlow) {
|
||||||
|
return {
|
||||||
|
error: 'Invalid kiroIDCFlow. Supported: authcode, device',
|
||||||
|
code: 'INVALID_KIRO_IDC_FLOW',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!options.kiroIDCStartUrl) {
|
||||||
|
return {
|
||||||
|
error: 'Kiro IDC login requires kiroIDCStartUrl',
|
||||||
|
code: 'MISSING_KIRO_IDC_START_URL',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getStartUrlUnsupportedReason(
|
export function getStartUrlUnsupportedReason(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
options?: { kiroMethod?: KiroAuthMethod }
|
options?: { kiroMethod?: KiroAuthMethod }
|
||||||
@@ -600,6 +640,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
const noIncognitoBody =
|
const noIncognitoBody =
|
||||||
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
||||||
const kiroMethodRaw = requestBody.kiroMethod;
|
const kiroMethodRaw = requestBody.kiroMethod;
|
||||||
|
const kiroIDCStartUrl =
|
||||||
|
typeof requestBody.kiroIDCStartUrl === 'string'
|
||||||
|
? requestBody.kiroIDCStartUrl.trim()
|
||||||
|
: undefined;
|
||||||
|
const kiroIDCRegion =
|
||||||
|
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
|
||||||
|
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
|
||||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||||
const target = getProxyTarget();
|
const target = getProxyTarget();
|
||||||
if (target.isRemote) {
|
if (target.isRemote) {
|
||||||
@@ -609,6 +656,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||||
const nickname = nicknameRaw?.trim();
|
const nickname = nicknameRaw?.trim();
|
||||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||||
|
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
|
||||||
|
|
||||||
// Validate provider
|
// Validate provider
|
||||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||||
@@ -624,6 +672,18 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'kiro') {
|
||||||
|
const kiroIDCValidationError = getKiroStartIDCValidationError({
|
||||||
|
kiroMethod,
|
||||||
|
kiroIDCStartUrl,
|
||||||
|
invalidKiroIDCFlow,
|
||||||
|
});
|
||||||
|
if (kiroIDCValidationError) {
|
||||||
|
res.status(400).json(kiroIDCValidationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
|
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
|
||||||
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
|
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
@@ -662,6 +722,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
|||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
acceptAgyRisk: provider === 'agy',
|
acceptAgyRisk: provider === 'agy',
|
||||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||||
|
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
|
||||||
|
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
|
||||||
|
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
|
||||||
fromUI: true, // Enable project selection prompt in UI
|
fromUI: true, // Enable project selection prompt in UI
|
||||||
noIncognito, // Kiro: use normal browser if enabled
|
noIncognito, // Kiro: use normal browser if enabled
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { readOptionValue } from '../../../src/cliproxy/executor/index';
|
||||||
|
|
||||||
|
describe('readOptionValue', () => {
|
||||||
|
it('parses split-token option values', () => {
|
||||||
|
expect(readOptionValue(['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], '--kiro-idc-start-url')).toEqual({
|
||||||
|
present: true,
|
||||||
|
value: 'https://d-123.awsapps.com/start',
|
||||||
|
missingValue: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses equals-form option values', () => {
|
||||||
|
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
|
||||||
|
present: true,
|
||||||
|
value: 'device',
|
||||||
|
missingValue: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks empty or missing values as invalid', () => {
|
||||||
|
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
|
||||||
|
present: true,
|
||||||
|
value: undefined,
|
||||||
|
missingValue: true,
|
||||||
|
});
|
||||||
|
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
|
||||||
|
present: true,
|
||||||
|
value: undefined,
|
||||||
|
missingValue: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,20 @@ describe('requestPasteCallbackStart', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('usesKiroLocalCallbackReplay', () => {
|
||||||
|
it('limits local callback replay to CLI auth-code flows', async () => {
|
||||||
|
const { usesKiroLocalCallbackReplay } = await import(
|
||||||
|
`../../../src/cliproxy/auth/oauth-handler?kiro-local-callback-mode=${Date.now()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usesKiroLocalCallbackReplay('aws-authcode', 'authcode')).toBe(true);
|
||||||
|
expect(usesKiroLocalCallbackReplay('idc', 'authcode')).toBe(true);
|
||||||
|
expect(usesKiroLocalCallbackReplay('idc', 'device')).toBe(false);
|
||||||
|
expect(usesKiroLocalCallbackReplay('google', 'authcode')).toBe(false);
|
||||||
|
expect(usesKiroLocalCallbackReplay('aws', 'authcode')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('resolvePasteCallbackAuthUrl', () => {
|
describe('resolvePasteCallbackAuthUrl', () => {
|
||||||
it('returns the immediate auth URL without polling', async () => {
|
it('returns the immediate auth URL without polling', async () => {
|
||||||
const { resolvePasteCallbackAuthUrl } = await import(
|
const { resolvePasteCallbackAuthUrl } = await import(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process';
|
import {
|
||||||
|
extractLikelyAuthFailureFromStderr,
|
||||||
|
getExpectedLocalCallback,
|
||||||
|
validateManualCallbackUrl,
|
||||||
|
} from '../../../src/cliproxy/auth/oauth-process';
|
||||||
|
|
||||||
describe('oauth-process stderr parsing', () => {
|
describe('oauth-process stderr parsing', () => {
|
||||||
it('ignores non-ghcp providers', () => {
|
it('ignores non-ghcp providers', () => {
|
||||||
@@ -33,3 +37,50 @@ describe('oauth-process stderr parsing', () => {
|
|||||||
expect((parsed as string).length).toBe(240);
|
expect((parsed as string).length).toBe(240);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('oauth-process manual callback validation', () => {
|
||||||
|
const authUrl =
|
||||||
|
'https://oidc.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state';
|
||||||
|
|
||||||
|
it('extracts the expected local callback target from the auth URL', () => {
|
||||||
|
expect(getExpectedLocalCallback(authUrl)).toEqual({
|
||||||
|
origin: 'http://127.0.0.1:9876',
|
||||||
|
pathname: '/oauth/callback',
|
||||||
|
state: 'test-state',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts matching loopback callback URLs', () => {
|
||||||
|
expect(
|
||||||
|
validateManualCallbackUrl(
|
||||||
|
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=test-state',
|
||||||
|
authUrl
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-loopback callback URLs', () => {
|
||||||
|
expect(
|
||||||
|
validateManualCallbackUrl(
|
||||||
|
'https://evil.example.com/oauth/callback?code=abc123&state=test-state',
|
||||||
|
authUrl
|
||||||
|
)
|
||||||
|
).toContain('local OAuth callback server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects callback URLs with the wrong path or state', () => {
|
||||||
|
expect(
|
||||||
|
validateManualCallbackUrl(
|
||||||
|
'http://127.0.0.1:9876/not-the-callback?code=abc123&state=test-state',
|
||||||
|
authUrl
|
||||||
|
)
|
||||||
|
).toContain('expected local OAuth callback target');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateManualCallbackUrl(
|
||||||
|
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=wrong-state',
|
||||||
|
authUrl
|
||||||
|
)
|
||||||
|
).toContain('state does not match');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
|
getKiroStartIDCValidationError,
|
||||||
getStartAuthFailureMessage,
|
getStartAuthFailureMessage,
|
||||||
getStartAuthNicknameError,
|
getStartAuthNicknameError,
|
||||||
getStartUrlUnsupportedReason,
|
getStartUrlUnsupportedReason,
|
||||||
@@ -38,6 +39,44 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('cliproxy-auth-routes Kiro IDC start validation', () => {
|
||||||
|
it('requires an IDC start URL when idc auth is selected', () => {
|
||||||
|
expect(
|
||||||
|
getKiroStartIDCValidationError({
|
||||||
|
kiroMethod: 'idc',
|
||||||
|
kiroIDCStartUrl: undefined,
|
||||||
|
invalidKiroIDCFlow: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
error: 'Kiro IDC login requires kiroIDCStartUrl',
|
||||||
|
code: 'MISSING_KIRO_IDC_START_URL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid IDC flow values before triggerOAuth is called', () => {
|
||||||
|
expect(
|
||||||
|
getKiroStartIDCValidationError({
|
||||||
|
kiroMethod: 'idc',
|
||||||
|
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
|
||||||
|
invalidKiroIDCFlow: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
error: 'Invalid kiroIDCFlow. Supported: authcode, device',
|
||||||
|
code: 'INVALID_KIRO_IDC_FLOW',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows valid IDC start payloads through', () => {
|
||||||
|
expect(
|
||||||
|
getKiroStartIDCValidationError({
|
||||||
|
kiroMethod: 'idc',
|
||||||
|
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
|
||||||
|
invalidKiroIDCFlow: false,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cliproxy-auth-routes start failure messaging', () => {
|
describe('cliproxy-auth-routes start failure messaging', () => {
|
||||||
it('returns ghcp-specific guidance for Copilot verification failures', () => {
|
it('returns ghcp-specific guidance for Copilot verification failures', () => {
|
||||||
expect(getStartAuthFailureMessage('ghcp')).toContain(
|
expect(getStartAuthFailureMessage('ghcp')).toContain(
|
||||||
|
|||||||
Reference in New Issue
Block a user