mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 12:23:35 +00:00
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:
@@ -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.'
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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' : ''}`} />
|
||||
|
||||
Reference in New Issue
Block a user