diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index 6c7e049d..f8f61e0c 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -234,11 +234,17 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void { if (!isBanWarningProvider(provider) || shownBanWarnings.has(provider)) return; shownBanWarnings.add(provider); + const isAgy = provider === 'agy'; console.error(''); console.error(warn('Account safety warning (#509 - read before continuing)')); console.error( ' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.' ); + if (isAgy) { + console.error( + ' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.' + ); + } console.error( ' This risk applies whether auth was done from CLI or from "ccs config" dashboard.' ); diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts new file mode 100644 index 00000000..c16722cd --- /dev/null +++ b/src/cliproxy/antigravity-responsibility.ts @@ -0,0 +1,225 @@ +/** + * Antigravity OAuth Responsibility Gate + * + * Enforces explicit user acknowledgement for Antigravity OAuth usage. + * This is used by: + * - CLI OAuth flow (`ccs agy --auth`) + * - CLI runtime flow (`ccs agy`) + * - Dashboard auth endpoints (server-side payload validation) + */ + +import { createInterface, Interface } from 'readline'; +import { fail, info, ok, warn } from '../utils/ui'; +import { getCliproxySafetyConfig } from '../config/unified-config-loader'; + +export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509'; +export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; +export const RISK_ACK_PHRASE = 'I ACCEPT RISK'; +export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE; +export const ANTIGRAVITY_ACCEPT_RISK_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk']; + +type AgyRiskContext = 'oauth' | 'run'; + +export interface AntigravityRiskAcknowledgement { + version: string; + reviewedIssue509: boolean; + understandsBanRisk: boolean; + acceptsFullResponsibility: boolean; + typedPhrase: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; +} + +interface EnsureCliRiskOptions { + context: AgyRiskContext; + acceptedByFlag?: boolean; +} + +function normalizePhrase(value: string): string { + return value.trim().replace(/\s+/g, ' ').toUpperCase(); +} + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} + +export function isAntigravityResponsibilityBypassEnabled(): boolean { + if (isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + return true; + } + + try { + const safety = getCliproxySafetyConfig(); + return safety.antigravity_ack_bypass === true; + } catch { + return false; + } +} + +function askQuestion(rl: Interface, prompt: string): Promise { + return new Promise((resolve) => { + let settled = false; + + const onClose = () => { + if (!settled) { + settled = true; + resolve(null); + } + }; + + rl.once('close', onClose); + rl.question(prompt, (answer) => { + if (settled) return; + settled = true; + rl.removeListener('close', onClose); + resolve(answer.trim()); + }); + }); +} + +async function askYesNoStep(rl: Interface, step: string, message: string): Promise { + while (true) { + const answer = await askQuestion( + rl, + `[?] ${step}\n ${message}\n Type YES to continue (NO to cancel): ` + ); + + if (answer === null) return false; + const normalized = answer.toUpperCase(); + if (normalized === 'YES') return true; + if (normalized === 'NO' || normalized === 'N' || normalized === '') return false; + console.error(warn('Please type YES or NO.')); + } +} + +async function askResponsibilityPhrase(rl: Interface): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const answer = await askQuestion( + rl, + `[?] Step 4/4\n Type exactly "${ANTIGRAVITY_ACK_PHRASE}": ` + ); + if (answer === null || answer === '') return false; + + if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) { + return true; + } + console.error(warn('Phrase mismatch. Try again.')); + } + return false; +} + +function printResponsibilityHeader(context: AgyRiskContext): void { + const contextLine = + context === 'oauth' + ? 'You are starting Antigravity OAuth account authorization.' + : 'You are starting a live Antigravity CLI session (ccs agy).'; + + console.error(''); + console.error('╔══════════════════════════════════════════════════════════════════════╗'); + console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║'); + console.error('╚══════════════════════════════════════════════════════════════════════╝'); + console.error(` ${contextLine}`); + console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.'); + console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`); + console.error(''); +} + +export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean { + return args.some((arg) => ANTIGRAVITY_ACCEPT_RISK_FLAGS.includes(arg)); +} + +export function validateAntigravityRiskAcknowledgement(payload: unknown): ValidationResult { + if (!payload || typeof payload !== 'object') { + return { + valid: false, + error: 'Antigravity OAuth requires a full responsibility acknowledgement payload.', + }; + } + + const data = payload as Partial; + + if (data.version !== ANTIGRAVITY_ACK_VERSION) { + return { + valid: false, + error: 'Antigravity acknowledgement version mismatch. Re-open add account and try again.', + }; + } + + if (!data.reviewedIssue509 || !data.understandsBanRisk || !data.acceptsFullResponsibility) { + return { + valid: false, + error: 'Complete all Antigravity responsibility checklist steps before authenticating.', + }; + } + + if ( + typeof data.typedPhrase !== 'string' || + normalizePhrase(data.typedPhrase) !== ANTIGRAVITY_ACK_PHRASE + ) { + return { + valid: false, + error: `Type exact acknowledgement phrase: "${ANTIGRAVITY_ACK_PHRASE}".`, + }; + } + + return { valid: true }; +} + +export async function ensureCliAntigravityResponsibility( + options: EnsureCliRiskOptions +): Promise { + if (options.acceptedByFlag || isAntigravityResponsibilityBypassEnabled()) { + return true; + } + + if (!process.stdin.isTTY || !process.stderr.isTTY) { + console.error(fail('Antigravity responsibility acknowledgement required.')); + console.error(' Re-run interactively and complete the 4-step confirmation.'); + console.error(' Non-interactive override: --accept-agr-risk'); + return false; + } + + printResponsibilityHeader(options.context); + + const rl = createInterface({ + input: process.stdin, + output: process.stderr, + }); + + try { + const step1 = await askYesNoStep( + rl, + 'Step 1/4', + 'I reviewed issue #509 and understand AGY OAuth can trigger bans/suspensions.' + ); + if (!step1) return false; + + const step2 = await askYesNoStep( + rl, + 'Step 2/4', + 'I understand this OAuth operation is my own decision and I choose to continue.' + ); + if (!step2) return false; + + const step3 = await askYesNoStep( + rl, + 'Step 3/4', + 'I accept that CCS provides no responsibility coverage for account loss, bans, or suspension.' + ); + if (!step3) return false; + + const step4 = await askResponsibilityPhrase(rl); + if (!step4) return false; + + console.error(ok('Antigravity responsibility acknowledgement accepted for this command.')); + console.error(info('Proceeding with Antigravity flow...')); + return true; + } finally { + rl.close(); + } +} diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 92080547..9d1cf401 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -273,6 +273,8 @@ export interface OAuthOptions { account?: string; add?: boolean; nickname?: string; + /** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */ + acceptAgyRisk?: boolean; /** Kiro auth method override (CLI + Dashboard parity). */ kiroMethod?: KiroAuthMethod; /** If true, triggered from Web UI (enables project selection prompt) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f05316d2..f4a2d3b2 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -50,6 +50,7 @@ import { warnOAuthBanRisk, warnPossible403Ban, } from '../account-safety'; +import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility'; /** * Prompt user to add another account @@ -429,10 +430,29 @@ export async function triggerOAuth( const oauthConfig = getOAuthConfig(provider); warnOAuthBanRisk(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; + const acceptAgyRisk = options.acceptAgyRisk === true; let { nickname } = options; const resolvedKiroMethod = provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; + if (provider === 'agy') { + if (fromUI && !acceptAgyRisk) { + console.log(fail('Antigravity OAuth blocked: responsibility acknowledgement is missing.')); + return null; + } + + if (!fromUI) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.log(info('Cancelled')); + return null; + } + } + } + // Check for existing accounts const existingAccounts = getProviderAccounts(provider); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 220ccdd2..ed24cd88 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -72,6 +72,11 @@ import { enforceProviderIsolation, restoreAutoPausedAccounts, } from '../account-safety'; +import { + ensureCliAntigravityResponsibility, + hasAntigravityRiskAcceptanceFlag, + ANTIGRAVITY_ACCEPT_RISK_FLAGS, +} from '../antigravity-responsibility'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { buildThinkingStartupStatus, @@ -286,6 +291,7 @@ export async function execClaudeWithCLIProxy( const addAccount = argsWithoutProxy.includes('--add'); const showAccounts = argsWithoutProxy.includes('--accounts'); const forceImport = argsWithoutProxy.includes('--import'); + const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy); const incognitoFlag = argsWithoutProxy.includes('--incognito'); const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); @@ -523,6 +529,38 @@ export async function execClaudeWithCLIProxy( log(`Using remote proxy authentication (skipping local OAuth)`); } + if (provider === 'agy' && forceAuth && skipLocalAuth) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + throw new Error( + `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ); + } + console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); + return; + } + + if (provider === 'agy' && !forceAuth) { + const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); + if (skipLocalAuth || !requiresAuthNow) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'run', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + console.error( + fail( + `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ) + ); + process.exit(1); + } + } + } + if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); @@ -536,6 +574,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(p, { verbose, add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), @@ -577,6 +616,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(provider, { verbose, add: addAccount, + ...(acceptAgyRisk ? { acceptAgyRisk: true } : {}), ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), @@ -922,6 +962,8 @@ export async function execClaudeWithCLIProxy( '--incognito', '--no-incognito', '--import', + '--accept-agr-risk', + '--accept-antigravity-risk', '--settings', ...PROXY_CLI_FLAGS, ]; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 4d028e97..1db7717c 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -167,6 +167,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'First run: Browser opens for authentication, then model selection', 'Settings: ~/.ccs/{provider}.settings.json (created after auth)', 'Safety: do not reuse one Google account across "ccs gemini" and "ccs agy" (issue #509)', + 'Antigravity requires multi-step responsibility confirmation (issue #509)', 'If you want to keep Google AI access, do not continue this shared-account setup', 'CCS is as-is and does not take responsibility for account bans/access loss', ], @@ -188,6 +189,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --accounts', 'List all accounts'], ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], + [ + 'ccs agy --accept-agr-risk', + 'Bypass interactive Antigravity confirmation (you accept full responsibility)', + ], [ 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8f82351c..1fbec374 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -18,10 +18,12 @@ import { DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, + CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, @@ -241,6 +243,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { accounts: partial.accounts ?? defaults.accounts, profiles: partial.profiles ?? defaults.profiles, cliproxy: { + ...partial.cliproxy, oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, providers: defaults.cliproxy.providers, // Always use defaults for providers variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, @@ -249,8 +252,17 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { request_log: partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, }, + safety: { + antigravity_ack_bypass: + partial.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }, + // Kiro browser behavior setting (optional) + kiro_no_incognito: partial.cliproxy?.kiro_no_incognito, // Auth config - preserve user values, no defaults (uses constants as fallback) auth: partial.cliproxy?.auth, + // Background token refresh config (optional) + token_refresh: partial.cliproxy?.token_refresh, // Backend selection - validate and preserve user choice (original vs plus) backend: partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' @@ -707,14 +719,24 @@ function generateYamlWithComments(config: UnifiedConfig): string { } /** - * Save unified config to YAML file. - * Uses atomic write (temp file + rename) to prevent corruption. - * Uses lockfile to prevent concurrent writes. + * Sync sleep helper for lock retry loops. + * Uses Atomics.wait when available to avoid CPU-intensive busy-wait. */ -export function saveUnifiedConfig(config: UnifiedConfig): void { - const yamlPath = getConfigYamlPath(); - const dir = path.dirname(yamlPath); +function sleepSync(ms: number): void { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + const end = Date.now() + ms; + while (Date.now() < end) { + /* busy-wait */ + } + } +} +/** + * Execute a callback while holding the config lock. + */ +function withConfigWriteLock(callback: () => T): T { // Acquire lock (retry for up to 1 second) const maxRetries = 10; const retryDelayMs = 100; @@ -724,18 +746,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { lockAcquired = true; break; } - // Synchronous sleep without CPU-intensive busy-wait - // Uses Atomics.wait which properly sleeps the thread - // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); - } catch { - // Fallback for environments without SharedArrayBuffer/Atomics support - const end = Date.now() + retryDelayMs; - while (Date.now() < end) { - /* busy-wait */ - } - } + sleepSync(retryDelayMs); } if (!lockAcquired) { @@ -743,57 +754,112 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { } try { - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; - - try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (error) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - } - // Classify filesystem errors - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOSPC') { - throw new Error('Disk full - cannot save config. Free up space and try again.'); - } else if (err.code === 'EROFS' || err.code === 'EACCES') { - throw new Error(`Cannot write config - check file permissions: ${err.message}`); - } - throw error; - } + return callback(); } finally { // Always release lock releaseLock(); } } +/** + * Load unified config directly from disk while lock is already held. + * Falls back to empty config when file doesn't exist. + */ +function loadUnifiedConfigWithLockHeld(): UnifiedConfig { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + return createEmptyUnifiedConfig(); + } + + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + throw new Error(`Invalid config format in ${yamlPath}`); + } + + const merged = mergeWithDefaults(parsed); + validateCompositeVariants(merged); + return merged; +} + +/** + * Write unified config to disk while lock is already held. + */ +function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } +} + +/** + * Save unified config to YAML file. + * Uses atomic write (temp file + rename) to prevent corruption. + * Uses lockfile to prevent concurrent writes. + */ +export function saveUnifiedConfig(config: UnifiedConfig): void { + withConfigWriteLock(() => { + writeUnifiedConfigWithLockHeld(config); + }); +} + +/** + * Atomically mutate unified config with lock held across read-modify-write. + * Prevents stale writes from overwriting concurrent updates. + */ +export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { + return withConfigWriteLock(() => { + const current = loadUnifiedConfigWithLockHeld(); + mutator(current); + writeUnifiedConfigWithLockHeld(current); + return current; + }); +} + /** * Update unified config with partial data. * Loads existing config, merges changes, and saves. */ export function updateUnifiedConfig(updates: Partial): UnifiedConfig { - const config = loadOrCreateUnifiedConfig(); - const updated = { ...config, ...updates }; - saveUnifiedConfig(updated); - return updated; + return mutateUnifiedConfig((config) => { + Object.assign(config, updates); + }); } /** @@ -892,6 +958,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get cliproxy safety configuration. + * Returns defaults if not configured. + */ +export function getCliproxySafetyConfig(): CLIProxySafetyConfig { + const config = loadOrCreateUnifiedConfig(); + return { + antigravity_ack_bypass: + config.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }; +} + /** * Get thinking configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 95924ea7..f668cf17 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -160,6 +160,22 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * CLIProxy safety configuration. + * Controls high-risk flow safeguards for supported providers. + */ +export interface CLIProxySafetyConfig { + /** Allow skipping AGY responsibility acknowledgement flow (default: false) */ + antigravity_ack_bypass?: boolean; +} + +/** + * Default CLIProxy safety configuration. + */ +export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = { + antigravity_ack_bypass: false, +}; + /** * Token refresh configuration. * Manages background token refresh worker settings. @@ -191,6 +207,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Safety controls for high-risk provider flows */ + safety?: CLIProxySafetyConfig; /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ @@ -781,6 +799,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, request_log: false, }, + safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG }, auto_sync: true, }, preferences: { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b07da8e2..940f9e7d 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -46,12 +46,34 @@ import { import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { + validateAntigravityRiskAcknowledgement, + isAntigravityResponsibilityBypassEnabled, +} from '../../cliproxy/antigravity-responsibility'; const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`); + return; + } + console.error(`[cliproxy-auth-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -163,12 +185,12 @@ router.get('/', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote auth status', authStatus: [], source: 'remote', }); } else { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to fetch auth status.'); } } }); @@ -198,13 +220,12 @@ router.get('/accounts', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote account status', accounts: [], source: 'remote', }); } else { - const message = error instanceof Error ? error.message : 'Failed to list accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to list accounts.'); } } }); @@ -225,8 +246,7 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { const accounts = getProviderAccounts(provider as CLIProxyProvider); res.json({ provider, accounts }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to get provider accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to get provider accounts.'); } }); @@ -268,8 +288,7 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to set default account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to set default account.'); } }); @@ -305,8 +324,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to remove account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to remove account.'); } }); @@ -338,8 +356,7 @@ router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to pause account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to pause account.'); } }); @@ -370,8 +387,7 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to resume account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to resume account.'); } }); @@ -381,13 +397,20 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { - nickname: nicknameRaw, - noIncognito: noIncognitoBody, - kiroMethod: kiroMethodRaw, - } = req.body; + const requestBody = + req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const noIncognitoBody = + typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined; + const kiroMethodRaw = requestBody.kiroMethod; + const riskAcknowledgement = requestBody.riskAcknowledgement; + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ error: 'OAuth start flow not available in remote mode' }); + return; + } // Trim nickname for consistency with CLI (oauth-handler.ts trims input) - const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; + const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Validate provider @@ -404,6 +427,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise }); } } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to import Kiro token.'); } }); @@ -596,7 +631,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { kiroMethod: kiroMethodRaw } = req.body ?? {}; + const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {}; const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode @@ -620,6 +655,17 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) { + const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement); + if (!validation.valid) { + res.status(400).json({ + error: validation.error, + code: 'AGY_RISK_ACK_REQUIRED', + }); + return; + } + } + const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, { kiroMethod: provider === 'kiro' ? kiroMethod : undefined, }); @@ -672,8 +718,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method: data.method || null, }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to start OAuth'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); @@ -787,8 +832,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P res.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to submit callback'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index f5cfb39b..54a3e265 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,11 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { + getDashboardAuthConfig, + loadOrCreateUnifiedConfig, + mutateUnifiedConfig, +} from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; const router = Router(); @@ -31,6 +36,73 @@ const MODEL_ENV_KEYS = [ ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[settings-routes] ${context}: ${error.message}`); + return; + } + console.error(`[settings-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + +function isLoopbackAddress(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().replace(/^\[|\]$/g, ''); + return ( + normalized === '::1' || + normalized === '127.0.0.1' || + normalized.startsWith('127.') || + normalized === '::ffff:127.0.0.1' || + normalized.startsWith('::ffff:127.') + ); +} + +function requireSensitiveLocalAccess(req: Request, res: Response): boolean { + const dashboardAuth = getDashboardAuthConfig(); + if (dashboardAuth.enabled) { + return true; + } + + const forwarded = req.headers['x-forwarded-for']; + const firstForwarded = + typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined; + const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip; + + if (isLoopbackAddress(candidateAddress)) { + return true; + } + + res.status(403).json({ + error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.', + }); + return false; +} + +function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + + if (message.includes('failed to acquire config lock')) { + return { statusCode: 409, message: 'Configuration is busy. Retry in a moment.' }; + } + if (message.includes('eacces') || message.includes('eperm') || message.includes('permission')) { + return { statusCode: 403, message: 'Insufficient permission to update configuration.' }; + } + if (message.includes('enospc') || message.includes('no space left')) { + return { statusCode: 507, message: 'Insufficient disk space to update configuration.' }; + } + + return { statusCode: 500, message: 'Failed to update Antigravity power user mode.' }; +} + /** * Helper: Resolve settings path for profile or variant * Variants have settings paths in config, regular profiles use {name}.settings.json @@ -144,7 +216,7 @@ router.get('/:profile', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -171,7 +243,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -264,7 +336,7 @@ router.put('/:profile', (req: Request, res: Response): void => { }), }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -286,7 +358,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -345,7 +417,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { res.status(201).json({ preset }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -377,12 +449,61 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => res.json({ success: true }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); // ==================== Auth Tokens ==================== +/** + * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting + */ +router.get('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const config = loadOrCreateUnifiedConfig(); + res.json({ + antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true, + }); + } catch (error) { + respondInternalError(res, error, 'Failed to load Antigravity power user mode.'); + } +}); + +/** + * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting + */ +router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + + try { + const body = req.body as { antigravityAckBypass?: unknown } | null | undefined; + const antigravityAckBypass = + body && typeof body === 'object' ? body.antigravityAckBypass : undefined; + + if (typeof antigravityAckBypass !== 'boolean') { + res.status(400).json({ error: 'antigravityAckBypass must be a boolean' }); + return; + } + + const updatedConfig = mutateUnifiedConfig((config) => { + config.cliproxy.safety = { + ...(config.cliproxy.safety ?? {}), + antigravity_ack_bypass: antigravityAckBypass, + }; + }); + + res.json({ + success: true, + antigravityAckBypass: updatedConfig.cliproxy?.safety?.antigravity_ack_bypass === true, + }); + } catch (error) { + const classified = classifyConfigSaveFailure(error); + respondInternalError(res, error, classified.message, classified.statusCode); + } +}); + /** * GET /api/settings/auth/tokens - Get current auth token status (masked) */ @@ -401,7 +522,7 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -409,7 +530,9 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { * GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked * NOTE: Sensitive endpoint - no caching, localhost only */ -router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { +router.get('/auth/tokens/raw', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { // Prevent caching of sensitive data res.setHeader('Cache-Control', 'no-store'); @@ -427,7 +550,7 @@ router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to load raw auth tokens.'); } }); @@ -463,7 +586,7 @@ router.put('/auth/tokens', (req: Request, res: Response): void => { message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -487,7 +610,7 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -515,7 +638,7 @@ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { message: 'Tokens reset to defaults. Restart CLIProxy to apply.', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); diff --git a/tests/unit/cliproxy/antigravity-responsibility.test.ts b/tests/unit/cliproxy/antigravity-responsibility.test.ts new file mode 100644 index 00000000..0777a60d --- /dev/null +++ b/tests/unit/cliproxy/antigravity-responsibility.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + ANTIGRAVITY_ACK_PHRASE, + ANTIGRAVITY_ACK_VERSION, + hasAntigravityRiskAcceptanceFlag, + isAntigravityResponsibilityBypassEnabled, + validateAntigravityRiskAcknowledgement, +} from '../../../src/cliproxy/antigravity-responsibility'; + +describe('antigravity-responsibility', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalAgyRiskEnv: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-risk-test-')); + originalCcsHome = process.env.CCS_HOME; + originalAgyRiskEnv = process.env.CCS_ACCEPT_AGY_RISK; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_ACCEPT_AGY_RISK; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalAgyRiskEnv !== undefined) { + process.env.CCS_ACCEPT_AGY_RISK = originalAgyRiskEnv; + } else { + delete process.env.CCS_ACCEPT_AGY_RISK; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('accepts a complete acknowledgement payload', () => { + const result = validateAntigravityRiskAcknowledgement({ + version: ANTIGRAVITY_ACK_VERSION, + reviewedIssue509: true, + understandsBanRisk: true, + acceptsFullResponsibility: true, + typedPhrase: ANTIGRAVITY_ACK_PHRASE, + }); + + expect(result.valid).toBeTrue(); + }); + + it('accepts phrase with extra spacing and lowercase', () => { + const result = validateAntigravityRiskAcknowledgement({ + version: ANTIGRAVITY_ACK_VERSION, + reviewedIssue509: true, + understandsBanRisk: true, + acceptsFullResponsibility: true, + typedPhrase: ' i accept risk ', + }); + + expect(result.valid).toBeTrue(); + }); + + it('rejects payload when checklist steps are not fully completed', () => { + const result = validateAntigravityRiskAcknowledgement({ + version: ANTIGRAVITY_ACK_VERSION, + reviewedIssue509: true, + understandsBanRisk: false, + acceptsFullResponsibility: true, + typedPhrase: ANTIGRAVITY_ACK_PHRASE, + }); + + expect(result.valid).toBeFalse(); + expect(result.error).toContain('checklist'); + }); + + it('rejects payload when version is outdated', () => { + const result = validateAntigravityRiskAcknowledgement({ + version: 'older-version', + reviewedIssue509: true, + understandsBanRisk: true, + acceptsFullResponsibility: true, + typedPhrase: ANTIGRAVITY_ACK_PHRASE, + }); + + expect(result.valid).toBeFalse(); + expect(result.error).toContain('version'); + }); + + it('rejects payload when phrase does not match', () => { + const result = validateAntigravityRiskAcknowledgement({ + version: ANTIGRAVITY_ACK_VERSION, + reviewedIssue509: true, + understandsBanRisk: true, + acceptsFullResponsibility: true, + typedPhrase: 'I AGREE', + }); + + expect(result.valid).toBeFalse(); + expect(result.error).toContain('phrase'); + }); + + it('detects explicit antigravity acceptance flags', () => { + expect(hasAntigravityRiskAcceptanceFlag(['--accept-agr-risk'])).toBeTrue(); + expect(hasAntigravityRiskAcceptanceFlag(['--accept-antigravity-risk'])).toBeTrue(); + expect(hasAntigravityRiskAcceptanceFlag(['--auth'])).toBeFalse(); + }); + + it('enables bypass when CCS_ACCEPT_AGY_RISK is set', () => { + process.env.CCS_ACCEPT_AGY_RISK = 'true'; + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); + + it('enables bypass when cliproxy safety setting is enabled', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 8 +cliproxy: + safety: + antigravity_ack_bypass: true +` + ); + + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); +}); diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index a2e00ae0..ad646ad1 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -1,24 +1,45 @@ -import { AlertTriangle, ExternalLink } from 'lucide-react'; +import { AlertTriangle, ExternalLink, Settings2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; +import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; interface AccountSafetyWarningCardProps { className?: string; showAcknowledgement?: boolean; - acknowledged?: boolean; - onAcknowledgedChange?: (value: boolean) => void; + acknowledgementPhrase?: string; + acknowledgementText?: string; + onAcknowledgementTextChange?: (value: string) => void; disabled?: boolean; + showProxySettingsLink?: boolean; } export function AccountSafetyWarningCard({ className, showAcknowledgement = false, - acknowledged = false, - onAcknowledgedChange, + acknowledgementPhrase = RISK_ACK_PHRASE, + acknowledgementText = '', + onAcknowledgementTextChange, disabled = false, + showProxySettingsLink = false, }: AccountSafetyWarningCardProps) { + const title = 'OAuth Account Safety Warning'; + const subtitle = 'Issue #509 · Gemini + AGY OAuth risk'; + const firstLine = ( + <> + Issue #509 documents suspension/ban reports tied to ccs agy{' '} + and shared-account usage between ccs gemini and{' '} + ccs agy. + + ); + const secondLine = ( + <>Continue only if you accept full responsibility for OAuth and account-access risk. + ); + const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509'; + const issueLabel = 'Read issue #509'; + const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy'; + return (
-

Account Safety Warning

-

- Issue #509 · Shared Gemini + AGY account risk -

+

{title}

+

{subtitle}

-

- Using one Google account for both ccs gemini and{' '} - ccs agy can trigger account disable/ban. -

-

- If you want to keep Google AI access, do not continue this shared-account setup. -

+

{firstLine}

+

{secondLine}

CCS is provided as-is and does not take responsibility for suspension, bans, or access loss from upstream providers. @@ -66,33 +80,44 @@ export function AccountSafetyWarningCard({

- Read issue #509 + {issueLabel} + {showProxySettingsLink && ( + + + {proxySettingsLabel} + + )} Applies to CLI and dashboard auth
- {showAcknowledgement && onAcknowledgedChange && ( + {showAcknowledgement && onAcknowledgementTextChange && (
-
- onAcknowledgedChange(Boolean(checked))} - disabled={disabled} - /> - -
+ + onAcknowledgementTextChange(e.target.value)} + placeholder={acknowledgementPhrase} + disabled={disabled} + className="mt-2 font-mono text-xs" + />
)}
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 8a203a9b..094ce6ff 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -7,7 +7,7 @@ * For Kiro: Also shows "Import from IDE" option. */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, DialogContent, @@ -25,11 +25,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react'; +import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from 'lucide-react'; import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; +import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist'; +import { + ANTIGRAVITY_ACK_VERSION, + DEFAULT_ANTIGRAVITY_RISK_CHECKLIST, + RISK_ACK_PHRASE, + isAntigravityRiskChecklistComplete, +} from '@/components/account/antigravity-responsibility-constants'; import { DEFAULT_KIRO_AUTH_METHOD, getKiroAuthMethodOption, @@ -49,6 +56,10 @@ interface AddAccountDialogProps { isFirstAccount?: boolean; } +function normalizeRiskPhrase(value: string): string { + return value.trim().replace(/\s+/g, ' ').toUpperCase(); +} + export function AddAccountDialog({ open, onClose, @@ -60,14 +71,21 @@ export function AddAccountDialog({ const [callbackUrl, setCallbackUrl] = useState(''); const [copied, setCopied] = useState(false); const [localError, setLocalError] = useState(null); - const [acknowledgedRisk, setAcknowledgedRisk] = useState(false); + const [riskAcknowledgementText, setRiskAcknowledgementText] = useState(''); + const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); + const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; - const requiresSafetyAcknowledgement = provider === 'gemini' || provider === 'agy'; + const requiresSafetyAcknowledgement = provider === 'gemini'; + const requiresAgyResponsibilityFlow = provider === 'agy' && !agyAckBypassEnabled; + const isAgyBypassStatePending = provider === 'agy' && agyAckBypassLoading; + const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); + const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); const requiresNickname = isNicknameRequiredProvider(provider); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); @@ -76,12 +94,24 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const errorMessage = localError || authFlow.error; + const fetchAgyBypassState = useCallback(async (): Promise => { + const response = await fetch('/api/settings/auth/antigravity-risk'); + if (!response.ok) { + throw new Error('Failed to load Antigravity power user setting'); + } + const data = (await response.json()) as { antigravityAckBypass?: boolean }; + return data.antigravityAckBypass === true; + }, []); + const resetAndClose = () => { setNickname(''); setCallbackUrl(''); setCopied(false); setLocalError(null); - setAcknowledgedRisk(false); + setRiskAcknowledgementText(''); + setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); @@ -89,11 +119,87 @@ export function AddAccountDialog({ useEffect(() => { if (open) { - setAcknowledgedRisk(false); + setRiskAcknowledgementText(''); + setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); setLocalError(null); } }, [provider, open]); + useEffect(() => { + let cancelled = false; + + if (!open || provider !== 'agy') { + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); + return; + } + + const loadAgyBypassState = async () => { + try { + setAgyAckBypassLoading(true); + const enabled = await fetchAgyBypassState(); + if (!cancelled) { + setAgyAckBypassEnabled(enabled); + } + } catch { + if (!cancelled) { + setAgyAckBypassEnabled(false); + } + } finally { + if (!cancelled) { + setAgyAckBypassLoading(false); + } + } + }; + + loadAgyBypassState(); + + return () => { + cancelled = true; + }; + }, [fetchAgyBypassState, open, provider]); + + useEffect(() => { + if (!open || provider !== 'agy' || !authFlow.error || !agyAckBypassEnabled) { + return; + } + + const normalizedError = authFlow.error.toLowerCase(); + const ackRequired = + normalizedError.includes('agy_risk_ack_required') || + normalizedError.includes('responsibility acknowledgement') || + normalizedError.includes('responsibility checklist'); + if (!ackRequired) return; + + let cancelled = false; + + const syncBypassState = async () => { + try { + setAgyAckBypassLoading(true); + const enabled = await fetchAgyBypassState(); + if (cancelled) return; + setAgyAckBypassEnabled(enabled); + if (!enabled) { + setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); + } + } catch { + if (cancelled) return; + setAgyAckBypassEnabled(false); + setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); + } finally { + if (!cancelled) { + setAgyAckBypassLoading(false); + } + } + }; + + void syncBypassState(); + + return () => { + cancelled = true; + }; + }, [agyAckBypassEnabled, authFlow.error, fetchAgyBypassState, open, provider]); + // When authFlow completes successfully (polling detected success), apply preset and close useEffect(() => { if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) { @@ -142,9 +248,19 @@ export function AddAccountDialog({ * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { - if (requiresSafetyAcknowledgement && !acknowledgedRisk) { + if (isAgyBypassStatePending) { + setLocalError('Loading Antigravity safety settings. Please wait a moment and retry.'); + return; + } + if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) { setLocalError( - 'Please acknowledge the account safety warning before authenticating this provider.' + 'Complete all Antigravity responsibility steps before authenticating this provider.' + ); + return; + } + if (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) { + setLocalError( + `Type "${RISK_ACK_PHRASE}" to acknowledge the account safety warning before authenticating this provider.` ); return; } @@ -159,6 +275,15 @@ export function AddAccountDialog({ kiroMethod: isKiro ? kiroAuthMethod : undefined, flowType: isKiro ? kiroMethodOption.flowType : undefined, startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + riskAcknowledgement: requiresAgyResponsibilityFlow + ? { + version: ANTIGRAVITY_ACK_VERSION, + reviewedIssue509: agyRiskChecklist.reviewedIssue509, + understandsBanRisk: agyRiskChecklist.understandsBanRisk, + acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility, + typedPhrase: agyRiskChecklist.typedPhrase, + } + : undefined, }); }; @@ -204,12 +329,35 @@ export function AddAccountDialog({
+ {requiresAgyResponsibilityFlow && !showAuthUI && ( + { + setAgyRiskChecklist(value); + setLocalError(null); + }} + disabled={isPending} + /> + )} + + {provider === 'agy' && agyAckBypassEnabled && !showAuthUI && ( +
+
+ + Power user mode enabled +
+ AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full + responsibility for OAuth/account risk. +
+ )} + {requiresSafetyAcknowledgement && !showAuthUI && ( { - setAcknowledgedRisk(value); + acknowledgementPhrase={RISK_ACK_PHRASE} + acknowledgementText={riskAcknowledgementText} + onAcknowledgementTextChange={(value) => { + setRiskAcknowledgementText(value); setLocalError(null); }} disabled={isPending} @@ -405,8 +553,10 @@ export function AddAccountDialog({ onClick={handleAuthenticate} disabled={ isPending || + isAgyBypassStatePending || (requiresNickname && !nicknameTrimmed) || - (requiresSafetyAcknowledgement && !acknowledgedRisk) + (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || + (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) } > diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx new file mode 100644 index 00000000..716fc130 --- /dev/null +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -0,0 +1,143 @@ +import { AlertTriangle, ExternalLink, ShieldAlert } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; +import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants'; + +interface AntigravityResponsibilityChecklistProps { + className?: string; + value: AntigravityRiskChecklistValue; + onChange: (value: AntigravityRiskChecklistValue) => void; + disabled?: boolean; +} + +export function AntigravityResponsibilityChecklist({ + className, + value, + onChange, + disabled = false, +}: AntigravityResponsibilityChecklistProps) { + const completedSteps = [ + value.reviewedIssue509, + value.understandsBanRisk, + value.acceptsFullResponsibility, + value.typedPhrase.trim().replace(/\s+/g, ' ').toUpperCase() === ANTIGRAVITY_ACK_PHRASE, + ].filter(Boolean).length; + const progressValue = (completedSteps / 4) * 100; + + const setValue = (next: Partial) => { + onChange({ ...value, ...next }); + }; + + return ( +
+
+ +
+
+
+
+ +
+
+

Antigravity OAuth Responsibility

+

+ Complete all 4 steps before you can authenticate. +

+
+
+ + Mandatory + +
+ +
+
+ Completion + {completedSteps}/4 steps +
+ +
+ +
+
+ setValue({ reviewedIssue509: Boolean(checked) })} + disabled={disabled} + /> + +
+ +
+ setValue({ understandsBanRisk: Boolean(checked) })} + disabled={disabled} + /> + +
+ +
+ + setValue({ acceptsFullResponsibility: Boolean(checked) }) + } + disabled={disabled} + /> + +
+
+ +
+
+ + Step 4: Type exact phrase to continue +
+ setValue({ typedPhrase: e.target.value })} + placeholder={ANTIGRAVITY_ACK_PHRASE} + disabled={disabled} + className="font-mono text-xs" + /> +
+ + +
+
+ ); +} diff --git a/ui/src/components/account/antigravity-responsibility-constants.ts b/ui/src/components/account/antigravity-responsibility-constants.ts new file mode 100644 index 00000000..621d408d --- /dev/null +++ b/ui/src/components/account/antigravity-responsibility-constants.ts @@ -0,0 +1,26 @@ +export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; +export const RISK_ACK_PHRASE = 'I ACCEPT RISK'; +export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE; + +export interface AntigravityRiskChecklistValue { + reviewedIssue509: boolean; + understandsBanRisk: boolean; + acceptsFullResponsibility: boolean; + typedPhrase: string; +} + +export const DEFAULT_ANTIGRAVITY_RISK_CHECKLIST: AntigravityRiskChecklistValue = { + reviewedIssue509: false, + understandsBanRisk: false, + acceptsFullResponsibility: false, + typedPhrase: '', +}; + +export function isAntigravityRiskChecklistComplete(value: AntigravityRiskChecklistValue): boolean { + return ( + value.reviewedIssue509 && + value.understandsBanRisk && + value.acceptsFullResponsibility && + value.typedPhrase.trim().replace(/\s+/g, ' ').toUpperCase() === ANTIGRAVITY_ACK_PHRASE + ); +} diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index a1f8f954..c31c42e8 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -28,6 +28,13 @@ interface StartAuthOptions { kiroMethod?: string; flowType?: 'authorization_code' | 'device_code'; startEndpoint?: 'start' | 'start-url'; + riskAcknowledgement?: { + version: string; + reviewedIssue509: boolean; + understandsBanRisk: boolean; + acceptsFullResponsibility: boolean; + typedPhrase: string; + }; } /** Polling interval for OAuth status check (3 seconds) */ @@ -35,6 +42,19 @@ const POLL_INTERVAL = 3000; /** Maximum polling duration (5 minutes) */ const MAX_POLL_DURATION = 5 * 60 * 1000; +async function parseResponseBody(response: Response): Promise> { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as Record; + } catch { + const fallbackError = + response.status >= 400 ? `Request failed with status ${response.status}` : undefined; + return fallbackError ? { error: fallbackError } : {}; + } +} + /** Initial state for auth flow - extracted for DRY */ const INITIAL_STATE: AuthFlowState = { provider: null, @@ -175,6 +195,7 @@ export function useCliproxyAuthFlow() { const payload = { nickname: options?.nickname, kiroMethod: options?.kiroMethod, + riskAcknowledgement: options?.riskAcknowledgement, }; setState({ @@ -198,8 +219,9 @@ export function useCliproxyAuthFlow() { signal: controller.signal, }) .then(async (response) => { - const data = await response.json(); - if (response.ok && data.success) { + const data = await parseResponseBody(response); + const success = data.success === true; + if (response.ok && success) { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast @@ -207,7 +229,8 @@ export function useCliproxyAuthFlow() { openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else { - const errorMsg = data.error || 'Authentication failed'; + const errorMsg = + typeof data.error === 'string' ? data.error : 'Authentication failed'; toast.error(errorMsg); setState((prev) => ({ ...prev, @@ -239,30 +262,35 @@ export function useCliproxyAuthFlow() { signal: controller.signal, }); - const data = await response.json(); + const data = await parseResponseBody(response); + const success = data.success === true; - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to start OAuth'); + if (!response.ok || !success) { + const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth'; + throw new Error(errorMsg); } + const authUrl = typeof data.authUrl === 'string' ? data.authUrl : null; + const oauthState = typeof data.state === 'string' ? data.state : null; + // Update state with auth URL setState((prev) => ({ ...prev, - authUrl: data.authUrl || null, - oauthState: data.state, + authUrl, + oauthState, })); // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) - if (data.authUrl) { + if (authUrl) { openedAuthUrlRef.current = true; - window.open(data.authUrl, '_blank'); + window.open(authUrl, '_blank'); } // Start polling for completion - if (data.state) { + if (oauthState) { pollStartRef.current = Date.now(); pollIntervalRef.current = setInterval(() => { - pollStatus(provider, data.state); + pollStatus(provider, oauthState); }, POLL_INTERVAL); } } @@ -311,16 +339,19 @@ export function useCliproxyAuthFlow() { body: JSON.stringify({ redirectUrl }), }); - const data = await response.json(); + const data = await parseResponseBody(response); + const success = data.success === true; - if (response.ok && data.success) { + if (response.ok && success) { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${state.provider} authentication successful`); setState(INITIAL_STATE); } else { - throw new Error(data.error || 'Callback submission failed'); + const errorMsg = + typeof data.error === 'string' ? data.error : 'Callback submission failed'; + throw new Error(errorMsg); } } catch (error) { const message = error instanceof Error ? error.message : 'Failed to submit callback'; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index e75d645c..5f6229b7 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -397,7 +397,9 @@ export function CliproxyPage() { {/* Right Panel */}
- {showAccountSafetyWarning && } + {showAccountSafetyWarning && ( + + )} {selectedVariantData && parentAuthForVariant ? ( // Variant selected - show ProviderEditor with variant profile name diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index 5d1c9772..bfbbb59a 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -197,6 +197,13 @@ export default function AuthSection() { setTimeout(() => setCopiedSecret(false), 2000); }; + const refreshAll = async () => { + if (loading || saving) return; + setError(null); + setSuccess(null); + await Promise.all([fetchTokens(), fetchRawConfig()]); + }; + if (loading || !tokens) { return (
@@ -359,7 +366,6 @@ export default function AuthSection() {
- {/* Actions */}
+ {/* Safety */} +
+

+ + Safety +

+
+
+
+

Antigravity Power User Mode

+

+ Skip AGY responsibility checklist in Add Account and `ccs agy` flows. +

+
+ +
+

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS + cannot assume responsibility for account loss. +

+ {showAgyEnableConfirm && ( +
+
+

+ Final confirmation required +

+

+ Enabling this will skip AGY safety checkpoints in both dashboard and CLI. + Review issue #509 and type the exact phrase to proceed. +

+
+
+
+

+ Step 1 +

+ + Read issue #509 + + +
+
+

+ Step 2 +

+

+ Type{' '} + + {RISK_ACK_PHRASE} + {' '} + to enable. +

+
+
+
+ setAgyEnableConfirmPhrase(e.target.value)} + placeholder={RISK_ACK_PHRASE} + disabled={agyAckBypassSaving || saving} + className="font-mono text-xs" + aria-label="Type I ACCEPT RISK to enable Antigravity power user mode" + /> +

+ Exact phrase required. +

+
+
+ + +
+
+ )} + + Toggle AGY power user mode + +
+
+ {/* Remote Settings - Show when remote mode is enabled */} {isRemoteMode && ( { fetchConfig(); + fetchAgyAckBypass(); fetchRawConfig(); fetchBackend(); checkPlusOnlyVariants(); }} - disabled={loading || saving} + disabled={loading || saving || agyAckBypassSaving} className="w-full" >