Merge pull request #916 from kaitranntt/kai/fix/kiro-auth-912-913-914

fix(kiro): align auth flows with CLIProxyAPIPlus
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-06 15:52:42 -04:00
committed by GitHub
21 changed files with 1584 additions and 132 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-04
Last Updated: 2026-04-05
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`.
- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help <topic|command>` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend.
- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared.
- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user.
+3
View File
@@ -757,6 +757,9 @@ async function main(): Promise<void> {
'--port-forward',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--backend',
'--proxy-host',
'--proxy-port',
+83 -10
View File
@@ -22,14 +22,19 @@ import {
* - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only)
* - google: Social OAuth via Google
* - github: Social OAuth via GitHub (management API only)
* - idc: IAM Identity Center (IDC) via CLI flags with start URL + region
*/
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const;
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
/** CLI binary supports these Kiro methods directly via flags. */
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const;
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'idc'] as const;
export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number];
export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const;
export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number];
export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode';
/** Default Kiro method for CCS UX and AWS Organization support. */
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
@@ -41,18 +46,40 @@ export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod {
return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod);
}
export function isKiroIDCFlow(value: string): value is KiroIDCFlow {
return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow);
}
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
const normalized = value.trim().toLowerCase();
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
}
export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean {
return method === 'aws';
export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow {
if (!value) return DEFAULT_KIRO_IDC_FLOW;
const normalized = value.trim().toLowerCase();
return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW;
}
export function getKiroCallbackPort(method: KiroAuthMethod): number | null {
return isKiroDeviceCodeMethod(method) ? null : 9876;
export function isKiroDeviceCodeMethod(
method: KiroAuthMethod,
options?: { idcFlow?: KiroIDCFlow }
): boolean {
if (method === 'aws') {
return true;
}
if (method === 'idc') {
return normalizeKiroIDCFlow(options?.idcFlow) === 'device';
}
return false;
}
export function getKiroCallbackPort(
method: KiroAuthMethod,
options?: { idcFlow?: KiroIDCFlow }
): number | null {
return isKiroDeviceCodeMethod(method, options) ? null : 9876;
}
export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
@@ -63,19 +90,49 @@ export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
return '--kiro-aws-authcode';
case 'google':
return '--kiro-google-login';
case 'idc':
return '--kiro-idc-login';
}
}
export function getKiroCLIAuthArgs(
method: KiroCLIAuthMethod,
options?: {
idcStartUrl?: string;
idcRegion?: string;
idcFlow?: KiroIDCFlow;
}
): string[] {
if (method !== 'idc') {
return [getKiroCLIAuthFlag(method)];
}
const startUrl = options?.idcStartUrl?.trim();
if (!startUrl) {
throw new Error('Kiro IDC login requires --kiro-idc-start-url');
}
const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl];
const region = options?.idcRegion?.trim();
if (region) {
args.push('--kiro-idc-region', region);
}
args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options?.idcFlow));
return args;
}
/**
* Kiro method for CLIProxyAPI management endpoint:
* GET /v0/management/kiro-auth-url?method=<value>
*/
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' {
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' | null {
switch (method) {
case 'google':
return 'google';
case 'github':
return 'github';
case 'idc':
return null;
case 'aws-authcode':
return 'aws';
case 'aws':
@@ -258,10 +315,20 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string {
return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`;
}
export function getPasteCallbackStartPath(provider: CLIProxyProvider): string {
// Kiro CLI auth methods still use the legacy start route.
export function getPasteCallbackStartPath(
provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod }
): string | null {
if (provider === 'kiro') {
return `/oauth/${provider}/start`;
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'aws-authcode' || kiroMethod === 'idc') {
return null;
}
const managementMethod = toKiroManagementMethod(kiroMethod);
if (!managementMethod) {
return null;
}
return `${getManagementAuthUrlPath(provider)}&method=${encodeURIComponent(managementMethod)}`;
}
return getManagementAuthUrlPath(provider);
}
@@ -294,6 +361,12 @@ export interface OAuthOptions {
acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */
kiroMethod?: KiroAuthMethod;
/** Kiro IDC start URL (required when kiroMethod=idc). */
kiroIDCStartUrl?: string;
/** Kiro IDC region override. */
kiroIDCRegion?: string;
/** Kiro IDC flow override (authcode or device). */
kiroIDCFlow?: KiroIDCFlow;
/** If true, triggered from Web UI (enables project selection prompt) */
fromUI?: boolean;
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
+316 -72
View File
@@ -11,6 +11,7 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { fail, info, warn, color, ok } from '../../utils/ui';
import { ensureCLIProxyBinary } from '../binary-manager';
import { generateConfig } from '../config-generator';
@@ -32,8 +33,9 @@ import {
import {
OAuthOptions,
DEFAULT_KIRO_AUTH_METHOD,
DEFAULT_KIRO_IDC_FLOW,
getKiroCallbackPort,
getKiroCLIAuthFlag,
getKiroCLIAuthArgs,
isKiroCLIAuthMethod,
isKiroDeviceCodeMethod,
getOAuthConfig,
@@ -42,9 +44,15 @@ import {
getPasteCallbackStartPath,
getManagementOAuthCallbackPath,
normalizeKiroAuthMethod,
normalizeKiroIDCFlow,
} from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
import {
getProviderTokenDir,
isAuthenticated,
isTokenFileForProvider,
registerAccountFromToken,
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import {
@@ -69,18 +77,28 @@ interface PasteCallbackStartData {
}
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
};
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider);
const startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
throw new Error(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
const response = await fetch(buildProxyUrl(target, startPath), {
...(provider === 'kiro' ? { method: 'POST' } : {}),
headers:
provider === 'kiro'
? buildManagementHeaders(target, { 'Content-Type': 'application/json' })
: buildManagementHeaders(target),
headers: buildManagementHeaders(target),
});
if (!response.ok) {
@@ -116,6 +134,134 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseAuthUrlState(url: string | null | undefined): string | null {
if (!url) {
return null;
}
try {
return new URL(url).searchParams.get('state');
} catch {
return null;
}
}
function listProviderTokenSnapshots(
provider: CLIProxyProvider,
tokenDir: string
): ProviderTokenSnapshot[] {
if (!fs.existsSync(tokenDir)) {
return [];
}
return fs
.readdirSync(tokenDir)
.filter((file) => file.endsWith('.json'))
.map((file): ProviderTokenSnapshot | null => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) {
return null;
}
return {
file,
mtimeMs: fs.statSync(filePath).mtimeMs,
};
})
.filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null)
.sort((left, right) => right.mtimeMs - left.mtimeMs);
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
const knownTokenMtimes = new Map(
knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
);
return (
listProviderTokenSnapshots(provider, tokenDir).find((snapshot) => {
const knownMtime = knownTokenMtimes.get(snapshot.file);
if (knownMtime === undefined) {
return true;
}
if (!expectedAccountId) {
return false;
}
return snapshot.mtimeMs > knownMtime + 1;
}) || null
);
}
async function waitForManualCallbackToken(
provider: CLIProxyProvider,
target: ProxyTarget,
tokenDir: string,
oauthState: string | null,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId: string | undefined,
timeoutMs: number,
pollIntervalMs: number = PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS
): Promise<{ tokenSnapshot: ProviderTokenSnapshot | null; error?: string }> {
const deadline = Date.now() + timeoutMs;
let upstreamCompletedAt: number | null = null;
while (Date.now() < deadline) {
const tokenSnapshot = findNewTokenSnapshotForManualAuth(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (tokenSnapshot) {
return { tokenSnapshot };
}
if (oauthState) {
const response = await fetch(
buildProxyUrl(
target,
`/v0/management/get-auth-status?state=${encodeURIComponent(oauthState)}`
),
{ headers: buildManagementHeaders(target) }
);
if (response.ok) {
const data = (await response.json()) as { status?: string; error?: string };
if (data.status === 'error') {
return {
tokenSnapshot: null,
error: data.error || 'Authentication failed while waiting for local token persistence',
};
}
if (data.status === 'ok' && upstreamCompletedAt === null) {
upstreamCompletedAt = Date.now();
}
}
}
if (
upstreamCompletedAt !== null &&
Date.now() - upstreamCompletedAt >= POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
) {
break;
}
if (Date.now() + pollIntervalMs >= deadline) {
break;
}
await sleep(pollIntervalMs);
}
return { tokenSnapshot: null };
}
export async function resolvePasteCallbackAuthUrl(
target: ProxyTarget,
startData: PasteCallbackStartData,
@@ -297,6 +443,57 @@ async function prepareBinary(
}
}
function buildOAuthArgs(
provider: CLIProxyProvider,
configPath: string,
headless: boolean,
noIncognito: boolean,
options: {
kiroMethod?: OAuthOptions['kiroMethod'];
kiroIDCStartUrl?: string;
kiroIDCRegion?: string;
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
} = {}
): string[] {
const args = ['--config', configPath];
if (provider === 'kiro') {
const method = normalizeKiroAuthMethod(options.kiroMethod);
if (!isKiroCLIAuthMethod(method)) {
throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`);
}
args.push(
...getKiroCLIAuthArgs(method, {
idcStartUrl: options.kiroIDCStartUrl,
idcRegion: options.kiroIDCRegion,
idcFlow: options.kiroIDCFlow,
})
);
} else {
args.push(getOAuthConfig(provider).authFlag);
}
if (headless) {
args.push('--no-browser');
}
if (provider === 'kiro' && noIncognito) {
args.push('--no-incognito');
}
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
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
@@ -307,7 +504,8 @@ async function handlePasteCallbackMode(
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -318,12 +516,13 @@ async function handlePasteCallbackMode(
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
try {
// Request auth URL from CLIProxyAPI.
// Kiro keeps its legacy start route because CLI auth methods do not share the generic
// management auth-url contract used by providers like Claude.
// Request auth URL from CLIProxyAPI management endpoints when the selected
// provider/method supports the manual start-url contract.
let startData: PasteCallbackStartData;
try {
startData = await requestPasteCallbackStart(provider, target);
startData = await requestPasteCallbackStart(provider, target, {
kiroMethod: options?.kiroMethod,
});
} catch (error) {
const startError = (error as Error).message;
console.log(fail('Failed to start OAuth flow'));
@@ -338,6 +537,9 @@ async function handlePasteCallbackMode(
return null;
}
const oauthState = startData.state || parseAuthUrlState(authUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
// Display auth URL in box
console.log('');
console.log(' ╔══════════════════════════════════════════════════════════════╗');
@@ -430,15 +632,49 @@ async function handlePasteCallbackMode(
return null;
}
console.log(ok('Authentication successful!'));
console.log(info('Callback submitted. Waiting for token exchange...'));
const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken(
provider,
target,
tokenDir,
oauthState,
knownTokenFiles,
expectedAccountId,
OAUTH_STATE_TIMEOUT_MS
);
if (tokenWaitError) {
console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError);
return null;
}
if (!tokenSnapshot) {
console.log(
fail(
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
)
);
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId
tokenSnapshot.file
);
if (!account) {
console.log(
fail('Authenticated token could not be matched to the requested account. Retry the flow.')
);
return null;
}
console.log(ok('Authentication successful!'));
// Account safety: check for cross-provider conflicts
if (account?.email) {
const conflicts = checkNewAccountConflict(provider, account.email);
@@ -475,6 +711,8 @@ export async function triggerOAuth(
const { nickname } = options;
const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -505,19 +743,6 @@ export async function triggerOAuth(
return null;
}
// Handle paste-callback mode
if (options.pasteCallback) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// Handle --import flag: skip OAuth and import from Kiro IDE directly
if (options.import && provider === 'kiro') {
const tokenDir = getProviderTokenDir(provider);
@@ -535,52 +760,44 @@ export async function triggerOAuth(
}
const callbackPort =
provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider];
provider === 'kiro'
? getKiroCallbackPort(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
: OAUTH_PORTS[provider];
const isCLI = !fromUI;
const headless = options.headless ?? isHeadlessEnvironment();
const isDeviceCodeFlow =
provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null;
provider === 'kiro'
? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
: callbackPort === null;
let selectedPasteCallback = options.pasteCallback === true;
let authFlag = oauthConfig.authFlag;
if (provider === 'kiro') {
if (!isKiroCLIAuthMethod(resolvedKiroMethod)) {
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
console.log(' Use Dashboard management OAuth for this method.');
return null;
}
authFlag = getKiroCLIAuthFlag(resolvedKiroMethod);
if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) {
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
console.log(' Use Dashboard management OAuth for this method.');
return null;
}
// Interactive mode selection for headless environments
// 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
if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
selectedPasteCallback = true;
} else {
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
selectedPasteCallback = true;
}
}
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) {
console.log('');
console.log(
@@ -595,6 +812,19 @@ export async function triggerOAuth(
}
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
);
}
// Pre-flight checks (skip for device code flows which don't need callback ports)
if (!isDeviceCodeFlow && !(await runPreflightChecks(provider, oauthConfig))) {
return null;
@@ -617,14 +847,18 @@ export async function triggerOAuth(
}
}
// Build args
const args = ['--config', configPath, authFlag];
if (headless) {
args.push('--no-browser');
}
// Kiro-specific: --no-incognito to use normal browser (saves login credentials)
if (provider === 'kiro' && noIncognito) {
args.push('--no-incognito');
const processHeadless = selectedPasteCallback && provider === 'kiro' ? true : headless;
let args: string[];
try {
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
kiroIDCStartUrl: options.kiroIDCStartUrl,
kiroIDCRegion: options.kiroIDCRegion,
kiroIDCFlow: provider === 'kiro' ? resolvedKiroIDCFlow : undefined,
});
} catch (error) {
console.log(fail((error as Error).message));
return null;
}
// Show step based on flow type
@@ -636,7 +870,14 @@ export async function triggerOAuth(
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
// Show headless instructions (only for authorization code flows)
if (headless) {
if (useSelectedKiroLocalPasteCallback) {
console.log('');
console.log(info('Paste-callback mode enabled for Kiro CLI auth.'));
console.log(
' CCS will print the authorization URL and wait for you to paste the final callback URL.'
);
console.log('');
} else if (headless) {
console.log('');
console.log(warn('PORT FORWARDING REQUIRED'));
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
@@ -656,11 +897,14 @@ export async function triggerOAuth(
tokenDir,
oauthConfig,
callbackPort,
headless,
headless: processHeadless,
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
manualCallback: useSelectedKiroLocalPasteCallback,
});
// Show hint for Kiro users about --no-incognito option (first-time auth only)
+300 -17
View File
@@ -22,7 +22,7 @@ import {
type GCloudProject,
type ProjectSelectionPrompt,
} from '../project-selection-handler';
import { ProviderOAuthConfig } from './auth-types';
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-manager';
import {
@@ -51,6 +51,9 @@ export interface OAuthProcessOptions {
isCLI: boolean;
nickname?: string;
expectedAccountId?: string;
authFlowType?: 'device_code' | 'authorization_code';
kiroMethod?: KiroAuthMethod;
manualCallback?: boolean;
}
/** Internal state for OAuth process */
@@ -66,6 +69,9 @@ interface ProcessState {
deviceCodeDisplayed: boolean;
/** The user code to enter at verification URL */
userCode: string | null;
kiroMethodSelectionHandled: boolean;
manualCallbackPrompted: boolean;
cancelManualCallbackPrompt: (() => void) | null;
}
/**
@@ -106,6 +112,231 @@ async function handleProjectSelection(
}
}
function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' {
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
}
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;
}
export function getKiroBuilderIdSelectionInput(output: string): string | null {
const promptMatch = /Select login method/i.exec(output);
if (!promptMatch || promptMatch.index === undefined) {
return null;
}
const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600);
const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*.*\bBuilder ID\b/im.exec(promptWindow);
if (!optionMatch) {
return null;
}
return `${optionMatch[1]}\n`;
}
export function extractLikelyOAuthAuthorizationUrl(output: string): string | null {
const urls = Array.from(output.matchAll(/https?:\/\/[^\s]+/g), (match) => match[0]);
let selectedUrl: string | null = null;
let selectedScore = 0;
for (const url of urls) {
try {
const parsed = new URL(url);
let score = 0;
if (parsed.searchParams.has('redirect_uri')) score += 4;
if (parsed.searchParams.has('state')) score += 2;
if (parsed.searchParams.has('code_challenge')) score += 1;
if (parsed.pathname.includes('/authorize')) score += 1;
if (isLoopbackHost(parsed.hostname)) score -= 3;
if (score >= selectedScore && score > 0) {
selectedUrl = url;
selectedScore = score;
}
} catch {
continue;
}
}
return selectedUrl;
}
async function promptManualCallbackUrl(
displayName: string,
state: ProcessState,
timeoutMs: number
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((resolve) => {
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', () => {
finish(null);
});
console.log('');
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.');
rl.question('> ', (answer) => {
rl.close();
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(
oauthConfig: ProviderOAuthConfig,
authProcess: ChildProcess,
authUrl: string,
verbose: boolean,
state: ProcessState,
timeoutMs: number
): Promise<boolean> {
if (!authUrl.includes('http://') && !authUrl.includes('https://')) {
return false;
}
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs);
if (!callbackUrl) {
console.log(info('Cancelled'));
killWithEscalation(authProcess);
return true;
}
const validationError = validateManualCallbackUrl(callbackUrl, authUrl);
if (validationError) {
console.log(fail(validationError));
killWithEscalation(authProcess);
return true;
}
console.log(info('Replaying callback to the local auth server...'));
try {
const response = await fetch(callbackUrl);
if (!response.ok && response.status >= 400) {
console.log(fail(`OAuth callback failed with status ${response.status}`));
killWithEscalation(authProcess);
return true;
}
console.log(ok('Callback submitted. Waiting for token exchange...'));
} catch (error) {
if (verbose) {
console.log(fail(`Failed to replay callback: ${(error as Error).message}`));
} else {
console.log(fail('Failed to replay callback to the local auth server'));
}
killWithEscalation(authProcess);
}
return true;
}
/**
* Handle stdout data from OAuth process
*/
@@ -119,10 +350,23 @@ async function handleStdout(
log(`stdout: ${output.trim()}`);
state.accumulatedOutput += output;
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
if (
options.provider === 'kiro' &&
options.kiroMethod === 'aws' &&
!state.kiroMethodSelectionHandled &&
state.accumulatedOutput.includes('Select login method')
) {
const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput);
if (builderIdSelection) {
state.kiroMethodSelectionHandled = true;
authProcess.stdin?.write(builderIdSelection);
log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`);
}
}
// Parse project list when available
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
state.parsedProjects = parseProjectList(state.accumulatedOutput);
@@ -191,13 +435,25 @@ async function handleStdout(
// Display OAuth URL for all modes (enables VS Code terminal URL detection popup)
if (!isDeviceCodeFlow && !state.urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
const authUrl = extractLikelyOAuthAuthorizationUrl(state.accumulatedOutput);
if (authUrl) {
console.log('');
console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
if (options.manualCallback && !state.manualCallbackPrompted) {
state.manualCallbackPrompted = true;
await replayManualCallback(
options.oauthConfig,
authProcess,
authUrl,
options.verbose,
state,
10 * 60 * 1000
);
}
}
}
}
@@ -208,11 +464,11 @@ function displayUrlFromStderr(
state: ProcessState,
oauthConfig: ProviderOAuthConfig
): void {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
const authUrl = extractLikelyOAuthAuthorizationUrl(output);
if (authUrl) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
}
@@ -386,14 +642,17 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
};
return new Promise<AccountInfo | null>((resolve) => {
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code';
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
// H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts)
// Device Code flows may need interactive stdin for email/prompts
// Authorization Code flows need piped stdin for project selection
const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe';
// Device-code flows can usually inherit stdin, but Kiro's default AWS flow now
// prints an intermediate Builder ID vs IDC selector that CCS auto-answers.
const stdinMode =
isDeviceCodeFlow &&
process.stdin.isTTY &&
!(provider === 'kiro' && options.kiroMethod === 'aws')
? 'inherit'
: 'pipe';
const authProcess = spawn(binaryPath, args, {
stdio: [stdinMode, 'pipe', 'pipe'],
@@ -424,6 +683,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
sessionId: generateSessionId(),
deviceCodeDisplayed: false,
userCode: null,
kiroMethodSelectionHandled: false,
manualCallbackPrompted: false,
cancelManualCallbackPrompt: null,
};
// Register session for cancellation support
@@ -459,13 +721,27 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
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();
state.stderrData += output;
log(`stderr: ${output.trim()}`);
if (headless && !state.urlDisplayed) {
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
@@ -500,10 +776,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// Timeout handling
// 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(() => {
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers before killing process
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
@@ -523,6 +804,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
@@ -585,6 +867,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
+142 -7
View File
@@ -65,7 +65,14 @@ import {
} from '../../utils/image-analysis';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
import {
isKiroAuthMethod,
isKiroIDCFlow,
KiroAuthMethod,
KiroIDCFlow,
normalizeKiroAuthMethod,
normalizeKiroIDCFlow,
} from '../auth/auth-types';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
// Import modular components
@@ -110,6 +117,34 @@ const DEFAULT_CONFIG: ExecutorConfig = {
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)
*
@@ -339,31 +374,112 @@ export async function execClaudeWithCLIProxy(
// Parse --kiro-auth-method flag
let kiroAuthMethod: KiroAuthMethod | undefined;
const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method');
if (kiroMethodIdx !== -1) {
const rawMethod = argsWithoutProxy[kiroMethodIdx + 1];
if (!rawMethod || rawMethod.startsWith('-')) {
const kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method');
if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github');
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
return;
}
const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
console.error(' Supported values: aws, aws-authcode, google, github');
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
return;
}
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
}
let kiroIDCStartUrl: string | undefined;
const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url');
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} else if (kiroIDCStartUrlValue.present) {
console.error(fail('--kiro-idc-start-url requires a value'));
process.exitCode = 1;
return;
}
let kiroIDCRegion: string | undefined;
const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region');
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value;
} else if (kiroIDCRegionValue.present) {
console.error(fail('--kiro-idc-region requires a value'));
process.exitCode = 1;
return;
}
let kiroIDCFlow: KiroIDCFlow | undefined;
const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow');
if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value'));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return;
}
const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return;
}
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
return;
}
if (
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
provider !== 'kiro' &&
!compositeProviders.includes('kiro')
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
);
process.exitCode = 1;
return;
}
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
kiroAuthMethod = 'idc';
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
console.error(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
);
process.exitCode = 1;
return;
}
if (
kiroAuthMethod &&
kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
);
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -533,6 +649,9 @@ export async function execClaudeWithCLIProxy(
verbose,
import: true,
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
@@ -597,6 +716,9 @@ export async function execClaudeWithCLIProxy(
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -639,6 +761,9 @@ export async function execClaudeWithCLIProxy(
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -1052,6 +1177,9 @@ export async function execClaudeWithCLIProxy(
'--use',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--thinking',
'--effort',
'--1m',
@@ -1066,6 +1194,10 @@ export async function execClaudeWithCLIProxy(
];
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
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('--effort=')) return false;
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
@@ -1073,6 +1205,9 @@ export async function execClaudeWithCLIProxy(
argsWithoutProxy[idx - 1] === '--use' ||
argsWithoutProxy[idx - 1] === '--nickname' ||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-start-url' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-region' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-flow' ||
argsWithoutProxy[idx - 1] === '--thinking' ||
argsWithoutProxy[idx - 1] === '--effort'
)
+2 -1
View File
@@ -2,7 +2,7 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
export interface HelpTopicEntry {
name: HelpTopicName;
@@ -25,6 +25,7 @@ export interface ShortcutEntry {
export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
] as const;
+9 -1
View File
@@ -220,7 +220,15 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
if (command === 'kiro') {
return completeSubcommands(
[],
[...PROVIDER_FLAGS, '--kiro-auth-method', '--import', '--incognito']
[
...PROVIDER_FLAGS,
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--import',
'--incognito',
]
);
}
return completeSubcommands([], PROVIDER_FLAGS);
+73
View File
@@ -78,6 +78,7 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
},
{ name: 'ccs api create --preset <id>', summary: 'Create an API-backed provider profile' },
{ name: 'ccs config', summary: 'Use the dashboard for provider and model setup' },
{ name: 'ccs help kiro', summary: 'Kiro-specific auth methods and IDC flags' },
],
writeLine
);
@@ -85,6 +86,74 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
writeLine('');
}
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Kiro Help'));
writeLine('');
writeLine(' Kiro supports Builder ID, IDC, and management-only social OAuth flows.');
writeLine('');
writeCommandTable(
'Authentication Methods',
[
{ name: 'ccs kiro --auth', summary: 'Default AWS Builder ID device-code flow' },
{
name: 'ccs kiro --auth --kiro-auth-method aws-authcode',
summary: 'AWS Builder ID auth-code flow via local callback server',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc',
summary: 'IAM Identity Center flow; requires IDC start URL',
},
{
name: 'ccs config',
summary: 'Dashboard flow for GitHub OAuth and account management',
},
],
writeLine
);
writeCommandTable(
'Kiro Flags',
[
{
name: '--kiro-auth-method <aws|aws-authcode|google|github|idc>',
summary: 'Select the Kiro auth method',
},
{ name: '--kiro-idc-start-url <url>', summary: 'Required IDC start URL when using `idc`' },
{ name: '--kiro-idc-region <region>', summary: 'Optional IDC region override' },
{ name: '--kiro-idc-flow <authcode|device>', summary: 'IDC flow type; defaults to authcode' },
{
name: '--paste-callback',
summary: 'Paste the final callback URL for callback-based CLI auth flows',
},
{ name: '--import', summary: 'Import an existing Kiro IDE token instead of starting OAuth' },
],
writeLine
);
writeCommandTable(
'Examples',
[
{ name: 'ccs kiro --auth', summary: 'Start the default Builder ID device flow' },
{
name: 'ccs kiro --auth --kiro-auth-method aws-authcode --paste-callback',
summary: 'Use auth-code flow and paste the callback URL manually',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start',
summary: 'Start IDC auth with the default authcode flow',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start --kiro-idc-flow device',
summary: 'Use IDC device-code flow instead of authcode',
},
],
writeLine
);
writeLine(
` ${dim('GitHub OAuth is dashboard-only: ccs config -> Accounts -> Add Kiro account')}`
);
writeLine('');
}
async function showTargetsHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Targets Help'));
@@ -176,6 +245,10 @@ export async function handleHelpRoute(
await showProvidersHelp(writeLine);
return;
}
if (topic === 'kiro') {
await showKiroHelp(writeLine);
return;
}
if (topic === 'targets') {
await showTargetsHelp(writeLine);
return;
+71 -4
View File
@@ -45,8 +45,11 @@ import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
isKiroAuthMethod,
isKiroIDCFlow,
isKiroDeviceCodeMethod,
KiroIDCFlow,
KiroAuthMethod,
normalizeKiroIDCFlow,
normalizeKiroAuthMethod,
toKiroManagementMethod,
} from '../../cliproxy/auth/auth-types';
@@ -256,12 +259,52 @@ function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boole
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(
provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod }
): string | null {
if (provider === 'kiro') {
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'idc') {
return "Kiro method 'idc' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
}
if (kiroMethod === 'aws-authcode') {
return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
}
@@ -597,6 +640,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const noIncognitoBody =
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
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 target = getProxyTarget();
if (target.isRemote) {
@@ -606,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)
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -615,12 +666,24 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
code: 'INVALID_KIRO_METHOD',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
kiroIDCStartUrl,
invalidKiroIDCFlow,
});
if (kiroIDCValidationError) {
res.status(400).json(kiroIDCValidationError);
return;
}
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -659,6 +722,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
nickname: nickname || undefined,
acceptAgyRisk: provider === 'agy',
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
noIncognito, // Kiro: use normal browser if enabled
});
@@ -828,7 +894,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
code: 'INVALID_KIRO_METHOD',
});
return;
@@ -867,9 +933,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
const kiroManagementMethod = provider === 'kiro' ? toKiroManagementMethod(kiroMethod) : null;
const kiroQuery =
provider === 'kiro'
? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}`
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
// Call CLIProxyAPI to start OAuth and get auth URL
@@ -20,8 +20,18 @@ describe('auth-types paste-callback start path', () => {
expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true');
});
it('keeps Kiro on the legacy start route for paste-callback mode', () => {
expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start');
it('maps Kiro management-supported methods to the management auth-url route', () => {
expect(getPasteCallbackStartPath('kiro')).toBe(
'/v0/management/kiro-auth-url?is_webui=true&method=aws'
);
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'google' })).toBe(
'/v0/management/kiro-auth-url?is_webui=true&method=google'
);
});
it('returns null for Kiro CLI-only paste-callback modes', () => {
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'aws-authcode' })).toBeNull();
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'idc' })).toBeNull();
});
it('still exposes the generic management auth-url helper', () => {
@@ -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,
});
});
});
@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
@@ -39,11 +42,10 @@ describe('requestPasteCallbackStart', () => {
expect(request.headers['Content-Type']).toBeUndefined();
});
it('keeps kiro on the legacy start route with POST', async () => {
it('uses the Kiro management auth-url route for paste-callback compatible methods', async () => {
mockFetch([
{
url: /\/oauth\/kiro\/start$/,
method: 'POST',
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=aws$/,
response: { auth_url: 'https://auth.example.com/kiro' },
},
]);
@@ -51,15 +53,43 @@ describe('requestPasteCallbackStart', () => {
const { requestPasteCallbackStart } = await import(
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
);
const startData = await requestPasteCallbackStart('kiro', remoteTarget);
const startData = await requestPasteCallbackStart('kiro', remoteTarget, {
kiroMethod: 'aws',
});
expect(startData.auth_url).toBe('https://auth.example.com/kiro');
const [request] = getCapturedFetchRequests();
expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start');
expect(request.method).toBe('POST');
expect(request.url).toBe(
'https://proxy.example.com:8317/v0/management/kiro-auth-url?is_webui=true&method=aws'
);
expect(request.method).toBe('GET');
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
expect(request.headers['Content-Type']).toBe('application/json');
expect(request.headers['Content-Type']).toBeUndefined();
});
it('throws for Kiro methods that require the local callback server flow', async () => {
const { requestPasteCallbackStart } = await import(
`../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}`
);
await expect(
requestPasteCallbackStart('kiro', remoteTarget, { kiroMethod: 'aws-authcode' })
).rejects.toThrow(/paste-callback start is not available/i);
});
});
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);
});
});
@@ -103,6 +133,56 @@ describe('resolvePasteCallbackAuthUrl', () => {
});
});
describe('findNewTokenSnapshotForManualAuth', () => {
it('detects newly created provider token files', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
const existingFile = path.join(tokenDir, 'kiro-existing.json');
fs.writeFileSync(existingFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
const existingMtimeMs = fs.statSync(existingFile).mtimeMs;
const { findNewTokenSnapshotForManualAuth } = await import(
`../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}`
);
const newFile = path.join(tokenDir, 'kiro-new.json');
fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' }));
const snapshot = findNewTokenSnapshotForManualAuth(
'kiro',
tokenDir,
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }]
);
expect(snapshot?.file).toBe('kiro-new.json');
fs.rmSync(tokenDir, { recursive: true, force: true });
});
it('treats a modified existing token as the new token during reauth', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-reauth-'));
const tokenFile = path.join(tokenDir, 'kiro-existing.json');
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
const existingMtimeMs = fs.statSync(tokenFile).mtimeMs;
const { findNewTokenSnapshotForManualAuth } = await import(
`../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}`
);
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true }));
const bumpedTime = new Date(existingMtimeMs + 10_000);
fs.utimesSync(tokenFile, bumpedTime, bumpedTime);
const snapshot = findNewTokenSnapshotForManualAuth(
'kiro',
tokenDir,
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }],
'kiro-existing.json'
);
expect(snapshot?.file).toBe('kiro-existing.json');
fs.rmSync(tokenDir, { recursive: true, force: true });
});
});
describe('getCliAuthNicknameError', () => {
it('allows omitted nicknames for no-email providers', async () => {
const { getCliAuthNicknameError } = await import(
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'bun:test';
import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process';
import {
extractLikelyAuthFailureFromStderr,
extractLikelyOAuthAuthorizationUrl,
getExpectedLocalCallback,
getKiroBuilderIdSelectionInput,
validateManualCallbackUrl,
} from '../../../src/cliproxy/auth/oauth-process';
describe('oauth-process stderr parsing', () => {
it('ignores non-ghcp providers', () => {
@@ -33,3 +39,107 @@ describe('oauth-process stderr parsing', () => {
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');
});
});
describe('oauth-process Kiro Builder ID menu parsing', () => {
it('selects Builder ID when it is the first option', () => {
const output = `
? Select login method:
1) Use with Builder ID (personal AWS account)
2) Use with IDC Account (organization SSO)
`;
expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n');
});
it('selects the Builder ID option even when upstream reorders the menu', () => {
const output = `
Select login method
1. IAM Identity Center
2. AWS Builder ID
`;
expect(getKiroBuilderIdSelectionInput(output)).toBe('2\n');
});
it('returns null when the Builder ID option is not present in the prompt window', () => {
const output = `
Select login method
1. IAM Identity Center
2. Google
`;
expect(getKiroBuilderIdSelectionInput(output)).toBeNull();
});
});
describe('oauth-process OAuth URL extraction', () => {
it('prefers the real auth URL over the IDC start URL banner', () => {
const authUrl =
'https://oidc.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
const output = `
Using IDC with Start URL: https://d-123.awsapps.com/start
Region: us-east-1
URL: ${authUrl}
`;
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
});
it('ignores local callback server URLs when the auth URL is also present', () => {
const authUrl =
'https://device.sso.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
const output = `
Callback server started, redirect URI: http://127.0.0.1:9876/oauth/callback
URL: ${authUrl}
`;
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
});
});
@@ -21,7 +21,9 @@ import {
import {
DEFAULT_KIRO_AUTH_METHOD,
getKiroCallbackPort,
getKiroCLIAuthArgs,
getKiroCLIAuthFlag,
normalizeKiroIDCFlow,
normalizeKiroAuthMethod,
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
toKiroManagementMethod,
@@ -136,20 +138,30 @@ describe('provider-capabilities', () => {
expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws');
expect(normalizeKiroAuthMethod()).toBe('aws');
expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google');
expect(normalizeKiroAuthMethod('IDC')).toBe('idc');
expect(normalizeKiroAuthMethod('not-valid')).toBe('aws');
expect(normalizeKiroIDCFlow()).toBe('authcode');
expect(normalizeKiroIDCFlow('DEVICE')).toBe('device');
expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login');
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login');
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual(
['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode']
);
expect(getKiroCallbackPort('aws')).toBeNull();
expect(getKiroCallbackPort('google')).toBe(9876);
expect(getKiroCallbackPort('github')).toBe(9876);
expect(getKiroCallbackPort('aws-authcode')).toBe(9876);
expect(getKiroCallbackPort('idc')).toBe(9876);
expect(getKiroCallbackPort('idc', { idcFlow: 'device' })).toBeNull();
expect(toKiroManagementMethod('aws')).toBe('aws');
expect(toKiroManagementMethod('aws-authcode')).toBe('aws');
expect(toKiroManagementMethod('google')).toBe('google');
expect(toKiroManagementMethod('github')).toBe('github');
expect(toKiroManagementMethod('idc')).toBeNull();
});
});
@@ -46,11 +46,23 @@ describe('help command parity', () => {
expect(rendered.includes('Built-in OAuth Providers')).toBe(true);
expect(rendered.includes('ccs cliproxy --help')).toBe(true);
expect(rendered.includes('ccs help kiro')).toBe(true);
expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true);
});
test('kiro topic documents IDC and callback flags', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['kiro'], writeLine));
expect(rendered.includes('CCS Kiro Help')).toBe(true);
expect(rendered.includes('--kiro-idc-start-url <url>')).toBe(true);
expect(rendered.includes('--kiro-idc-region <region>')).toBe(true);
expect(rendered.includes('--kiro-idc-flow <authcode|device>')).toBe(true);
expect(rendered.includes('--paste-callback')).toBe(true);
expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true);
});
test('completion topic documents install and verification paths', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import {
getKiroStartIDCValidationError,
getStartAuthFailureMessage,
getStartAuthNicknameError,
getStartUrlUnsupportedReason,
@@ -25,6 +26,12 @@ describe('cliproxy-auth-routes start-url guard', () => {
);
});
it('rejects Kiro idc method on start-url', () => {
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'idc' })).toContain(
"Kiro method 'idc' uses CLI auth flow"
);
});
it('allows authorization code providers', () => {
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
@@ -32,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', () => {
it('returns ghcp-specific guidance for Copilot verification failures', () => {
expect(getStartAuthFailureMessage('ghcp')).toContain(
@@ -39,11 +39,15 @@ import {
} from '@/components/account/antigravity-responsibility-constants';
import {
DEFAULT_KIRO_AUTH_METHOD,
DEFAULT_KIRO_IDC_FLOW,
getKiroEffectiveFlowType,
getKiroEffectiveStartEndpoint,
getKiroAuthMethodOption,
isKiroSocialAuthMethod,
isDeviceCodeProvider,
KIRO_AUTH_METHOD_OPTIONS,
} from '@/lib/provider-config';
import type { KiroAuthMethod } from '@/lib/provider-config';
import type { KiroAuthMethod, KiroIDCFlow } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -81,6 +85,9 @@ export function AddAccountDialog({
const [powerUserModeEnabled, setPowerUserModeEnabled] = useState(false);
const [powerUserModeLoading, setPowerUserModeLoading] = useState(false);
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState('');
const [kiroIDCRegion, setKiroIDCRegion] = useState('');
const [kiroIDCFlow, setKiroIDCFlow] = useState<KiroIDCFlow>(DEFAULT_KIRO_IDC_FLOW);
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false);
const powerUserModeRequestIdRef = useRef(0);
@@ -97,9 +104,19 @@ export function AddAccountDialog({
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
const defaultDeviceCode = isDeviceCodeProvider(provider);
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
const isKiroIdc = isKiro && kiroAuthMethod === 'idc';
const isKiroSocial = isKiro && isKiroSocialAuthMethod(kiroAuthMethod);
const selectedKiroFlowType = isKiro
? getKiroEffectiveFlowType(kiroAuthMethod, kiroIDCFlow)
: undefined;
const selectedKiroStartEndpoint = isKiro
? getKiroEffectiveStartEndpoint(kiroAuthMethod)
: undefined;
const isDeviceCode = isKiro ? selectedKiroFlowType === 'device_code' : defaultDeviceCode;
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
const nicknameTrimmed = nickname.trim();
const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim();
const kiroIDCRegionTrimmed = kiroIDCRegion.trim();
const errorMessage = localError || authFlow.error;
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
@@ -168,6 +185,9 @@ export function AddAccountDialog({
setPowerUserModeEnabled(false);
setPowerUserModeLoading(false);
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
setKiroIDCStartUrl('');
setKiroIDCRegion('');
setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW);
powerUserModeRequestIdRef.current += 1;
powerUserModeLoadErrorShownRef.current = false;
wasAuthenticatingRef.current = false;
@@ -282,12 +302,19 @@ export function AddAccountDialog({
return;
}
setLocalError(null);
if (isKiroIdc && !kiroIDCStartUrlTrimmed) {
setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.');
return;
}
wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, {
nickname: nicknameTrimmed || undefined,
kiroMethod: isKiro ? kiroAuthMethod : undefined,
flowType: isKiro ? kiroMethodOption.flowType : undefined,
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined,
flowType: isKiro ? selectedKiroFlowType : undefined,
startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined,
riskAcknowledgement: requiresAgyResponsibilityFlow
? {
version: ANTIGRAVITY_ACK_VERSION,
@@ -399,6 +426,77 @@ export function AddAccountDialog({
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
{isKiroSocial && (
<p className="text-xs text-muted-foreground">
If your browser does not return automatically after login, CCS can accept the
final
<span className="mx-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">
kiro://...
</span>
callback URL in the next step.
</p>
)}
</div>
)}
{isKiroIdc && !showAuthUI && (
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="space-y-2">
<Label htmlFor="kiro-idc-start-url">IDC Start URL</Label>
<Input
id="kiro-idc-start-url"
value={kiroIDCStartUrl}
onChange={(e) => {
setKiroIDCStartUrl(e.target.value);
setLocalError(null);
}}
placeholder="https://d-xxx.awsapps.com/start"
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Required for organization IAM Identity Center login.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="kiro-idc-region">IDC Region</Label>
<Input
id="kiro-idc-region"
value={kiroIDCRegion}
onChange={(e) => {
setKiroIDCRegion(e.target.value);
setLocalError(null);
}}
placeholder="us-east-1"
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Optional. Leave blank to use the upstream default region.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="kiro-idc-flow">IDC Flow</Label>
<Select
value={kiroIDCFlow}
onValueChange={(value) => {
setKiroIDCFlow(value as KiroIDCFlow);
setLocalError(null);
}}
>
<SelectTrigger id="kiro-idc-flow">
<SelectValue placeholder="Select IDC flow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="authcode">Authorization Code</SelectItem>
<SelectItem value="device">Device Code</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Auth Code opens a browser and may need the final callback URL pasted back. Device
Code shows a verification code instead.
</p>
</div>
</div>
)}
@@ -438,7 +536,9 @@ export function AddAccountDialog({
<p className="text-xs text-muted-foreground mt-1">
{authFlow.isDeviceCodeFlow
? t('addAccountDialog.deviceCodeHint')
: t('addAccountDialog.browserHint')}
: isKiroSocial
? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
: t('addAccountDialog.browserHint')}
</p>
</div>
@@ -486,13 +586,19 @@ export function AddAccountDialog({
{/* Callback paste field */}
<div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs">
{t('addAccountDialog.redirectPasteLabel')}
{isKiroSocial
? 'Browser did not return? Paste the final kiro:// callback URL:'
: t('addAccountDialog.redirectPasteLabel')}
</Label>
<Input
id="callback-url"
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={t('addAccountDialog.callbackPlaceholder')}
placeholder={
isKiroSocial
? 'kiro://kiro.kiroAgent/authenticate-success?code=...&state=...'
: t('addAccountDialog.callbackPlaceholder')
}
className="font-mono text-xs"
/>
<Button
@@ -516,7 +622,9 @@ export function AddAccountDialog({
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<p className="text-xs text-center text-muted-foreground">
{t('addAccountDialog.preparingUrl')}
{isKiroSocial
? 'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
: t('addAccountDialog.preparingUrl')}
</p>
)}
</div>
+21
View File
@@ -26,6 +26,9 @@ interface AuthFlowState {
interface StartAuthOptions {
nickname?: string;
kiroMethod?: string;
kiroIDCStartUrl?: string;
kiroIDCRegion?: string;
kiroIDCFlow?: 'authcode' | 'device';
flowType?: 'authorization_code' | 'device_code';
startEndpoint?: 'start' | 'start-url';
riskAcknowledgement?: {
@@ -70,6 +73,7 @@ const INITIAL_STATE: AuthFlowState = {
export function useCliproxyAuthFlow() {
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
const stateRef = useRef<AuthFlowState>(INITIAL_STATE);
const attemptIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -102,6 +106,10 @@ export function useCliproxyAuthFlow() {
};
}, [stopPolling]);
useEffect(() => {
stateRef.current = state;
}, [state]);
// Poll OAuth status
const pollStatus = useCallback(
async (provider: string, oauthState: string, attemptId: number) => {
@@ -253,6 +261,9 @@ export function useCliproxyAuthFlow() {
const payload = {
nickname: options?.nickname,
kiroMethod: options?.kiroMethod,
kiroIDCStartUrl: options?.kiroIDCStartUrl,
kiroIDCRegion: options?.kiroIDCRegion,
kiroIDCFlow: options?.kiroIDCFlow,
riskAcknowledgement: options?.riskAcknowledgement,
};
@@ -368,6 +379,16 @@ export function useCliproxyAuthFlow() {
// Start polling for completion
if (oauthState) {
pollStartRef.current = Date.now();
if (!authUrl) {
await pollStatus(provider, oauthState, attemptId);
if (!isActiveAttempt(attemptId)) {
return;
}
const currentState = stateRef.current;
if (!currentState.isAuthenticating || currentState.provider !== provider) {
return;
}
}
pollIntervalRef.current = setInterval(() => {
void pollStatus(provider, oauthState, attemptId);
}, POLL_INTERVAL);
+44 -1
View File
@@ -234,8 +234,11 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
}
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const;
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const;
export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number];
export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode';
export type KiroFlowType = 'authorization_code' | 'device_code';
export type KiroStartEndpoint = 'start' | 'start-url';
@@ -280,6 +283,13 @@ export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [
flowType: 'authorization_code',
startEndpoint: 'start-url',
},
{
id: 'idc',
label: 'AWS Identity Center (IDC)',
description: 'Use your organization start URL with auth code or device flow.',
flowType: 'authorization_code',
startEndpoint: 'start',
},
];
export function isKiroAuthMethod(value: string): value is KiroAuthMethod {
@@ -292,7 +302,40 @@ export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
}
export function isKiroIDCFlow(value: string): value is KiroIDCFlow {
return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow);
}
export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow {
if (!value) return DEFAULT_KIRO_IDC_FLOW;
const normalized = value.trim().toLowerCase();
return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW;
}
export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption {
const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method);
return option || KIRO_AUTH_METHOD_OPTIONS[0];
}
export function getKiroEffectiveFlowType(
method: KiroAuthMethod,
idcFlow: KiroIDCFlow = DEFAULT_KIRO_IDC_FLOW
): KiroFlowType {
if (method === 'aws') {
return 'device_code';
}
if (method === 'idc') {
return normalizeKiroIDCFlow(idcFlow) === 'device' ? 'device_code' : 'authorization_code';
}
return 'authorization_code';
}
export function getKiroEffectiveStartEndpoint(method: KiroAuthMethod): KiroStartEndpoint {
return method === 'google' || method === 'github' ? 'start-url' : 'start';
}
export function isKiroSocialAuthMethod(method: KiroAuthMethod): boolean {
return method === 'google' || method === 'github';
}
@@ -221,6 +221,96 @@ describe('useCliproxyAuthFlow', () => {
expect(toast.success).toHaveBeenCalledWith('codex authentication successful');
});
it('promotes a state-first auth bootstrap into an immediate auth URL without waiting for the first interval', async () => {
let pollCount = 0;
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/start-url')) {
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: null,
state: 'state-kiro-social',
})
);
}
if (url.includes('/status?state=state-kiro-social')) {
pollCount += 1;
return Promise.resolve(
createJsonResponse({
status: 'auth_url',
url: 'https://auth.example/kiro-social',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('kiro', { startEndpoint: 'start-url', kiroMethod: 'google' });
});
expect(result.current.authUrl).toBe('https://auth.example/kiro-social');
expect(result.current.oauthState).toBe('state-kiro-social');
expect(result.current.isAuthenticating).toBe(true);
expect(pollCount).toBe(1);
});
it('forwards Kiro IDC options to the backend start endpoint payload', async () => {
let requestBody: Record<string, unknown> | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/start')) {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return createJsonResponse({
success: true,
account: {
id: 'kiro-idc-account',
provider: 'kiro',
},
});
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('kiro', {
startEndpoint: 'start',
flowType: 'authorization_code',
kiroMethod: 'idc',
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
kiroIDCRegion: 'ca-central-1',
kiroIDCFlow: 'authcode',
});
});
expect(requestBody).toEqual({
nickname: undefined,
kiroMethod: 'idc',
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
kiroIDCRegion: 'ca-central-1',
kiroIDCFlow: 'authcode',
riskAcknowledgement: undefined,
});
});
it('treats callback responses without an account as failures', async () => {
vi.stubGlobal(
'fetch',