Merge pull request #623 from kaitranntt/kai/fix/agy-responsibility-gate

fix(agy): enforce multi-step responsibility acknowledgement for antigravity oauth
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-26 01:44:43 -05:00
committed by GitHub
19 changed files with 1465 additions and 167 deletions
+6
View File
@@ -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.'
);
+225
View File
@@ -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<string | null> {
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<boolean> {
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<boolean> {
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<AntigravityRiskAcknowledgement>;
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<boolean> {
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();
}
}
+2
View File
@@ -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) */
+20
View File
@@ -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);
+42
View File
@@ -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,
];
+5
View File
@@ -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 <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
[
'ccs agy --accept-agr-risk',
'Bypass interactive Antigravity confirmation (you accept full responsibility)',
],
[
'ccs <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
+137 -58
View File
@@ -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>): 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>): 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<T>(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>): 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.
+19
View File
@@ -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<string, CLIProxyVariantConfig | CompositeVariantConfig>;
/** 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: {
+72 -28
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<string, unknown>) : {};
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<voi
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;
}
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
@@ -451,6 +485,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
acceptAgyRisk: provider === 'agy',
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
@@ -471,7 +506,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
res.status(400).json({ error: 'Authentication failed or was cancelled' });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Failed to start OAuth flow.');
}
});
@@ -584,7 +619,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
});
}
} 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<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
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);
}
});
+135 -12
View File
@@ -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.');
}
});
@@ -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();
});
});
@@ -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 <code className="font-mono">ccs agy</code>{' '}
and shared-account usage between <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code>.
</>
);
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 (
<section
role="alert"
@@ -36,10 +57,8 @@ export function AccountSafetyWarningCard({
<AlertTriangle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold leading-5">Account Safety Warning</p>
<p className="text-xs text-muted-foreground">
Issue #509 · Shared Gemini + AGY account risk
</p>
<p className="text-sm font-semibold leading-5">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
</div>
<Badge
@@ -51,13 +70,8 @@ export function AccountSafetyWarningCard({
</div>
<div className="space-y-2 text-sm leading-relaxed">
<p>
Using one Google account for both <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code> can trigger account disable/ban.
</p>
<p className="font-medium text-amber-900 dark:text-amber-200">
If you want to keep Google AI access, do not continue this shared-account setup.
</p>
<p>{firstLine}</p>
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
<p className="text-xs text-muted-foreground">
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({
<div className="flex flex-wrap items-center gap-2">
<a
href="https://github.com/kaitranntt/ccs/issues/509"
href={issueUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
>
Read issue #509
{issueLabel}
<ExternalLink className="h-3.5 w-3.5" />
</a>
{showProxySettingsLink && (
<a
href="/settings?tab=proxy"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
>
<Settings2 className="h-3.5 w-3.5" />
{proxySettingsLabel}
</a>
)}
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
Applies to CLI and dashboard auth
</span>
</div>
{showAcknowledgement && onAcknowledgedChange && (
{showAcknowledgement && onAcknowledgementTextChange && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 p-2.5">
<div className="flex items-start gap-2">
<Checkbox
id="account-risk-ack"
checked={acknowledged}
onCheckedChange={(checked) => onAcknowledgedChange(Boolean(checked))}
disabled={disabled}
/>
<Label htmlFor="account-risk-ack" className="text-xs leading-5">
I understand this risk and that CCS takes no responsibility if I continue this
setup.
</Label>
</div>
<Label htmlFor="account-risk-ack-text" className="text-xs leading-5">
Type exact phrase to continue:{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono">
{acknowledgementPhrase}
</code>
</Label>
<Input
id="account-risk-ack-text"
value={acknowledgementText}
onChange={(e) => onAcknowledgementTextChange(e.target.value)}
placeholder={acknowledgementPhrase}
disabled={disabled}
className="mt-2 font-mono text-xs"
/>
</div>
)}
</div>
+162 -12
View File
@@ -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<string | null>(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<KiroAuthMethod>(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<boolean> => {
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({
</DialogHeader>
<div className="space-y-4 py-4">
{requiresAgyResponsibilityFlow && !showAuthUI && (
<AntigravityResponsibilityChecklist
value={agyRiskChecklist}
onChange={(value) => {
setAgyRiskChecklist(value);
setLocalError(null);
}}
disabled={isPending}
/>
)}
{provider === 'agy' && agyAckBypassEnabled && !showAuthUI && (
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" />
Power user mode enabled
</div>
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full
responsibility for OAuth/account risk.
</div>
)}
{requiresSafetyAcknowledgement && !showAuthUI && (
<AccountSafetyWarningCard
showAcknowledgement
acknowledged={acknowledgedRisk}
onAcknowledgedChange={(value) => {
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)
}
>
<ExternalLink className="w-4 h-4 mr-2" />
@@ -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<AntigravityRiskChecklistValue>) => {
onChange({ ...value, ...next });
};
return (
<section
role="alert"
className={cn(
'relative overflow-hidden rounded-xl border border-rose-500/35 bg-gradient-to-br from-rose-50 via-background to-amber-50/70 p-4 shadow-sm dark:from-rose-950/20 dark:to-amber-950/20',
className
)}
>
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-rose-500 via-orange-500 to-amber-500" />
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-md bg-rose-500/15 text-rose-700 dark:text-rose-300">
<AlertTriangle className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p>
<p className="text-xs text-muted-foreground">
Complete all 4 steps before you can authenticate.
</p>
</div>
</div>
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
Mandatory
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Completion</span>
<span>{completedSteps}/4 steps</span>
</div>
<Progress value={progressValue} className="h-2" />
</div>
<div className="space-y-3 rounded-lg border border-rose-500/20 bg-rose-500/5 p-3">
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-reviewed-issue"
checked={value.reviewedIssue509}
onCheckedChange={(checked) => setValue({ reviewedIssue509: Boolean(checked) })}
disabled={disabled}
/>
<Label htmlFor="agy-step-reviewed-issue" className="text-xs leading-5">
Step 1: I reviewed issue #509 and understand AGY OAuth can trigger account
bans/suspensions.
</Label>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-understands-risk"
checked={value.understandsBanRisk}
onCheckedChange={(checked) => setValue({ understandsBanRisk: Boolean(checked) })}
disabled={disabled}
/>
<Label htmlFor="agy-step-understands-risk" className="text-xs leading-5">
Step 2: I understand this OAuth action is my own decision and I accept the upstream
risk.
</Label>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-accept-responsibility"
checked={value.acceptsFullResponsibility}
onCheckedChange={(checked) =>
setValue({ acceptsFullResponsibility: Boolean(checked) })
}
disabled={disabled}
/>
<Label htmlFor="agy-step-accept-responsibility" className="text-xs leading-5">
Step 3: I accept full responsibility. CCS is not liable for suspension, bans, or
access loss.
</Label>
</div>
</div>
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-800 dark:text-amber-200">
<ShieldAlert className="h-3.5 w-3.5" />
Step 4: Type exact phrase to continue
</div>
<Input
value={value.typedPhrase}
onChange={(e) => setValue({ typedPhrase: e.target.value })}
placeholder={ANTIGRAVITY_ACK_PHRASE}
disabled={disabled}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<a
href="https://github.com/kaitranntt/ccs/issues/509"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 font-medium text-rose-800 transition-colors hover:bg-rose-500/15 dark:text-rose-200"
>
Read issue #509
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>
);
}
@@ -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
);
}
+46 -15
View File
@@ -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<Record<string, unknown>> {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} 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';
+3 -1
View File
@@ -397,7 +397,9 @@ export function CliproxyPage() {
{/* Right Panel */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{showAccountSafetyWarning && <AccountSafetyWarningCard className="mx-4 mt-4" />}
{showAccountSafetyWarning && (
<AccountSafetyWarningCard showProxySettingsLink className="mx-4 mt-4" />
)}
{selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name
@@ -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 (
<div className="flex-1 flex items-center justify-center">
@@ -359,7 +366,6 @@ export default function AuthSection() {
</div>
</div>
{/* Actions */}
<div className="pt-4 border-t">
<Button
variant="outline"
@@ -383,10 +389,7 @@ export default function AuthSection() {
<Button
variant="outline"
size="sm"
onClick={() => {
fetchTokens();
fetchRawConfig();
}}
onClick={refreshAll}
disabled={loading || saving}
className="flex-1"
>
+227 -4
View File
@@ -3,9 +3,10 @@
* Settings section for CLIProxyAPI configuration (local/remote)
*/
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import {
@@ -17,6 +18,8 @@ import {
Bug,
Box,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { useProxyConfig, useRawConfig } from '../../hooks';
import { useUpdateBackend, useProxyStatus } from '@/hooks/use-cliproxy';
@@ -25,6 +28,7 @@ import { RemoteProxyCard } from './remote-proxy-card';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import { toast } from 'sonner';
/** LocalStorage key for debug mode preference */
@@ -33,6 +37,10 @@ const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
function normalizeRiskAckPhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
}
export default function ProxySection() {
const {
config,
@@ -67,6 +75,14 @@ export default function ProxySection() {
return false;
}
});
const [agyAckBypass, setAgyAckBypass] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true);
const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false);
const [showAgyEnableConfirm, setShowAgyEnableConfirm] = useState(false);
const [agyEnableConfirmPhrase, setAgyEnableConfirmPhrase] = useState('');
const agyAckBypassSavingRef = useRef(false);
const isAgyConfirmPhraseValid =
normalizeRiskAckPhrase(agyEnableConfirmPhrase) === RISK_ACK_PHRASE;
const handleDebugModeChange = (enabled: boolean) => {
setDebugMode(enabled);
@@ -77,6 +93,101 @@ export default function ProxySection() {
}
};
const fetchAgyAckBypass = useCallback(async () => {
try {
setAgyAckBypassLoading(true);
const response = await fetch('/api/settings/auth/antigravity-risk');
if (!response.ok) {
throw new Error('Failed to load AGY power user mode');
}
const data = (await response.json()) as { antigravityAckBypass?: boolean };
setAgyAckBypass(data.antigravityAckBypass === true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode');
setAgyAckBypass(false);
} finally {
setAgyAckBypassLoading(false);
}
}, []);
const persistAgyAckBypass = useCallback(
async (nextValue: boolean) => {
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
try {
agyAckBypassSavingRef.current = true;
setAgyAckBypassSaving(true);
const response = await fetch('/api/settings/auth/antigravity-risk', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ antigravityAckBypass: nextValue }),
});
const payload = (await response.json()) as {
antigravityAckBypass?: boolean;
error?: string;
};
if (!response.ok) {
throw new Error(payload.error || 'Failed to update AGY power user mode');
}
const persistedValue = payload.antigravityAckBypass === true;
const verifyResponse = await fetch('/api/settings/auth/antigravity-risk', {
cache: 'no-store',
});
if (!verifyResponse.ok) {
throw new Error('Failed to verify AGY power user mode persistence');
}
const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean };
const verifiedValue = verifyData.antigravityAckBypass === true;
if (verifiedValue !== nextValue) {
throw new Error(
'AGY power user mode was not persisted. Config may have been modified by another process.'
);
}
setAgyAckBypass(verifiedValue && persistedValue);
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.');
await fetchRawConfig();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode');
} finally {
agyAckBypassSavingRef.current = false;
setAgyAckBypassSaving(false);
}
},
[agyAckBypassSaving, fetchRawConfig, saving]
);
const handleAgyAckBypassChange = useCallback(
(nextValue: boolean) => {
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
if (nextValue) {
setShowAgyEnableConfirm(true);
return;
}
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
void persistAgyAckBypass(false);
},
[agyAckBypassSaving, persistAgyAckBypass, saving]
);
const confirmAgyEnable = useCallback(() => {
if (!isAgyConfirmPhraseValid) {
toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`);
return;
}
void persistAgyAckBypass(true);
}, [isAgyConfirmPhraseValid, persistAgyAckBypass]);
// Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
@@ -138,12 +249,13 @@ export default function ProxySection() {
// Load data on mount
useEffect(() => {
fetchConfig();
void fetchAgyAckBypass();
fetchRawConfig();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Async data fetching on mount is intended
void fetchBackend();
void checkPlusOnlyVariants();
}, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
}, [fetchConfig, fetchAgyAckBypass, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
if (loading || !config) {
return (
@@ -403,6 +515,116 @@ export default function ProxySection() {
)}
</div>
{/* Safety */}
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
Safety
</h3>
<div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="font-medium text-sm">Antigravity Power User Mode</p>
<p className="text-xs text-muted-foreground">
Skip AGY responsibility checklist in Add Account and `ccs agy` flows.
</p>
</div>
<Switch
aria-labelledby="agy-power-user-mode-label"
aria-describedby="agy-power-user-mode-description"
checked={agyAckBypass}
disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
onCheckedChange={handleAgyAckBypassChange}
/>
</div>
<p
id="agy-power-user-mode-description"
className="text-xs text-amber-800/90 dark:text-amber-200/90"
>
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
cannot assume responsibility for account loss.
</p>
{showAgyEnableConfirm && (
<div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5">
<div className="space-y-1.5">
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
Final confirmation required
</p>
<p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90">
Enabling this will skip AGY safety checkpoints in both dashboard and CLI.
Review issue #509 and type the exact phrase to proceed.
</p>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 1
</p>
<a
href="https://github.com/kaitranntt/ccs/issues/509"
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200"
>
Read issue #509
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 2
</p>
<p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90">
Type{' '}
<code className="rounded bg-background/80 px-1 py-0.5 font-mono">
{RISK_ACK_PHRASE}
</code>{' '}
to enable.
</p>
</div>
</div>
<div className="space-y-2">
<Input
value={agyEnableConfirmPhrase}
onChange={(e) => 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"
/>
<p className="text-[11px] text-rose-800/90 dark:text-rose-200/80">
Exact phrase required.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
}}
disabled={agyAckBypassSaving || saving}
>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
onClick={confirmAgyEnable}
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
>
Enable Power User Mode
</Button>
</div>
</div>
)}
<span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode
</span>
</div>
</div>
{/* Remote Settings - Show when remote mode is enabled */}
{isRemoteMode && (
<RemoteProxyCard
@@ -517,11 +739,12 @@ export default function ProxySection() {
size="sm"
onClick={() => {
fetchConfig();
fetchAgyAckBypass();
fetchRawConfig();
fetchBackend();
checkPlusOnlyVariants();
}}
disabled={loading || saving}
disabled={loading || saving || agyAckBypassSaving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />