mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
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 (#622): OAuth usage can still trigger suspension/ban patterns.'
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.'
|
||||
);
|
||||
@@ -249,6 +255,9 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
|
||||
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
|
||||
);
|
||||
console.error(` Details: ${ISSUE_509_URL}`);
|
||||
if (isAgy) {
|
||||
console.error(' Antigravity details: https://github.com/kaitranntt/ccs/issues/622');
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/622';
|
||||
export const ANTIGRAVITY_PROJECT_ID_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/619';
|
||||
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v1';
|
||||
export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT FULL RESPONSIBILITY';
|
||||
export const ANTIGRAVITY_ACCEPT_RISK_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk'];
|
||||
|
||||
type AgyRiskContext = 'oauth' | 'run';
|
||||
|
||||
export interface AntigravityRiskAcknowledgement {
|
||||
version: string;
|
||||
reviewedIssue622: 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';
|
||||
}
|
||||
|
||||
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(` Related account reliability issue: ${ANTIGRAVITY_PROJECT_ID_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.reviewedIssue622 || !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 || isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) {
|
||||
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 #622 and understand Antigravity 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,24 @@ export async function execClaudeWithCLIProxy(
|
||||
log(`Using remote proxy authentication (skipping local OAuth)`);
|
||||
}
|
||||
|
||||
if (provider === 'agy' && !forceAuth && !skipLocalAuth) {
|
||||
const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider);
|
||||
if (!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 +560,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 +602,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 +948,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 #622)',
|
||||
'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)',
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { getOAuthFlowType } from '../../cliproxy/provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { validateAntigravityRiskAcknowledgement } from '../../cliproxy/antigravity-responsibility';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -385,6 +386,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
nickname: nicknameRaw,
|
||||
noIncognito: noIncognitoBody,
|
||||
kiroMethod: kiroMethodRaw,
|
||||
riskAcknowledgement,
|
||||
} = req.body;
|
||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
||||
@@ -404,6 +406,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'agy') {
|
||||
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 +464,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
|
||||
@@ -596,7 +610,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 +634,17 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'agy') {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
ANTIGRAVITY_ACK_PHRASE,
|
||||
ANTIGRAVITY_ACK_VERSION,
|
||||
hasAntigravityRiskAcceptanceFlag,
|
||||
validateAntigravityRiskAcknowledgement,
|
||||
} from '../../../src/cliproxy/antigravity-responsibility';
|
||||
|
||||
describe('antigravity-responsibility', () => {
|
||||
it('accepts a complete acknowledgement payload', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue622: 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,
|
||||
reviewedIssue622: true,
|
||||
understandsBanRisk: true,
|
||||
acceptsFullResponsibility: true,
|
||||
typedPhrase: ' i accept full responsibility ',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects payload when checklist steps are not fully completed', () => {
|
||||
const result = validateAntigravityRiskAcknowledgement({
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue622: 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',
|
||||
reviewedIssue622: 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,
|
||||
reviewedIssue622: 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();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface AccountSafetyWarningCardProps {
|
||||
className?: string;
|
||||
provider?: 'gemini' | 'agy';
|
||||
showAcknowledgement?: boolean;
|
||||
acknowledged?: boolean;
|
||||
onAcknowledgedChange?: (value: boolean) => void;
|
||||
@@ -14,11 +15,39 @@ interface AccountSafetyWarningCardProps {
|
||||
|
||||
export function AccountSafetyWarningCard({
|
||||
className,
|
||||
provider = 'gemini',
|
||||
showAcknowledgement = false,
|
||||
acknowledged = false,
|
||||
onAcknowledgedChange,
|
||||
disabled = false,
|
||||
}: AccountSafetyWarningCardProps) {
|
||||
const isAgy = provider === 'agy';
|
||||
|
||||
const title = isAgy ? 'Antigravity OAuth Risk' : 'Account Safety Warning';
|
||||
const subtitle = isAgy
|
||||
? 'Issue #622 · Third-party OAuth ban risk'
|
||||
: 'Issue #509 · Shared Gemini + AGY account risk';
|
||||
const firstLine = isAgy ? (
|
||||
<>
|
||||
Antigravity OAuth currently has active ban/suspension patterns. Complete the responsibility
|
||||
steps before running auth or starting <code className="font-mono">ccs agy</code>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
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.
|
||||
</>
|
||||
);
|
||||
const secondLine = isAgy ? (
|
||||
<>If you want to keep this account, do not continue unless you accept full responsibility.</>
|
||||
) : (
|
||||
<>If you want to keep Google AI access, do not continue this shared-account setup.</>
|
||||
);
|
||||
const issueUrl = isAgy
|
||||
? 'https://github.com/kaitranntt/ccs/issues/622'
|
||||
: 'https://github.com/kaitranntt/ccs/issues/509';
|
||||
const issueLabel = isAgy ? 'Read issue #622' : 'Read issue #509';
|
||||
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
@@ -36,10 +65,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 +78,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,12 +88,12 @@ 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>
|
||||
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -30,6 +30,12 @@ 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,
|
||||
isAntigravityRiskChecklistComplete,
|
||||
} from '@/components/account/antigravity-responsibility-constants';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroAuthMethodOption,
|
||||
@@ -61,13 +67,16 @@ export function AddAccountDialog({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [acknowledgedRisk, setAcknowledgedRisk] = useState(false);
|
||||
const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
|
||||
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';
|
||||
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const requiresNickname = isNicknameRequiredProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
@@ -82,6 +91,7 @@ export function AddAccountDialog({
|
||||
setCopied(false);
|
||||
setLocalError(null);
|
||||
setAcknowledgedRisk(false);
|
||||
setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
|
||||
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
|
||||
wasAuthenticatingRef.current = false;
|
||||
onClose();
|
||||
@@ -90,6 +100,7 @@ export function AddAccountDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAcknowledgedRisk(false);
|
||||
setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
|
||||
setLocalError(null);
|
||||
}
|
||||
}, [provider, open]);
|
||||
@@ -142,6 +153,12 @@ export function AddAccountDialog({
|
||||
* - Authorization code providers use /start-url and polling.
|
||||
*/
|
||||
const handleAuthenticate = () => {
|
||||
if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) {
|
||||
setLocalError(
|
||||
'Complete all Antigravity responsibility steps before authenticating this provider.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requiresSafetyAcknowledgement && !acknowledgedRisk) {
|
||||
setLocalError(
|
||||
'Please acknowledge the account safety warning before authenticating this provider.'
|
||||
@@ -159,6 +176,15 @@ export function AddAccountDialog({
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
flowType: isKiro ? kiroMethodOption.flowType : undefined,
|
||||
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
|
||||
riskAcknowledgement: requiresAgyResponsibilityFlow
|
||||
? {
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
reviewedIssue622: agyRiskChecklist.reviewedIssue622,
|
||||
understandsBanRisk: agyRiskChecklist.understandsBanRisk,
|
||||
acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility,
|
||||
typedPhrase: agyRiskChecklist.typedPhrase,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -204,8 +230,20 @@ export function AddAccountDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{requiresAgyResponsibilityFlow && !showAuthUI && (
|
||||
<AntigravityResponsibilityChecklist
|
||||
value={agyRiskChecklist}
|
||||
onChange={(value) => {
|
||||
setAgyRiskChecklist(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
disabled={isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requiresSafetyAcknowledgement && !showAuthUI && (
|
||||
<AccountSafetyWarningCard
|
||||
provider="gemini"
|
||||
showAcknowledgement
|
||||
acknowledged={acknowledgedRisk}
|
||||
onAcknowledgedChange={(value) => {
|
||||
@@ -406,6 +444,7 @@ export function AddAccountDialog({
|
||||
disabled={
|
||||
isPending ||
|
||||
(requiresNickname && !nicknameTrimmed) ||
|
||||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
|
||||
(requiresSafetyAcknowledgement && !acknowledgedRisk)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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,
|
||||
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.reviewedIssue622,
|
||||
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.reviewedIssue622}
|
||||
onCheckedChange={(checked) => setValue({ reviewedIssue622: Boolean(checked) })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label htmlFor="agy-step-reviewed-issue" className="text-xs leading-5">
|
||||
Step 1: I reviewed issue #622 and understand Antigravity 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/622"
|
||||
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 #622
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/kaitranntt/ccs/issues/619"
|
||||
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 font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
|
||||
>
|
||||
Related issue #619
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v1';
|
||||
export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT FULL RESPONSIBILITY';
|
||||
|
||||
export interface AntigravityRiskChecklistValue {
|
||||
reviewedIssue622: boolean;
|
||||
understandsBanRisk: boolean;
|
||||
acceptsFullResponsibility: boolean;
|
||||
typedPhrase: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANTIGRAVITY_RISK_CHECKLIST: AntigravityRiskChecklistValue = {
|
||||
reviewedIssue622: false,
|
||||
understandsBanRisk: false,
|
||||
acceptsFullResponsibility: false,
|
||||
typedPhrase: '',
|
||||
};
|
||||
|
||||
export function isAntigravityRiskChecklistComplete(value: AntigravityRiskChecklistValue): boolean {
|
||||
return (
|
||||
value.reviewedIssue622 &&
|
||||
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;
|
||||
reviewedIssue622: boolean;
|
||||
understandsBanRisk: boolean;
|
||||
acceptsFullResponsibility: boolean;
|
||||
typedPhrase: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Polling interval for OAuth status check (3 seconds) */
|
||||
@@ -175,6 +182,7 @@ export function useCliproxyAuthFlow() {
|
||||
const payload = {
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
riskAcknowledgement: options?.riskAcknowledgement,
|
||||
};
|
||||
|
||||
setState({
|
||||
|
||||
@@ -248,6 +248,7 @@ export function CliproxyPage() {
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const showAccountSafetyWarning = warningProvider === 'gemini' || warningProvider === 'agy';
|
||||
const warningProviderType = warningProvider === 'agy' ? 'agy' : 'gemini';
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
@@ -394,7 +395,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 provider={warningProviderType} className="mx-4 mt-4" />
|
||||
)}
|
||||
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
|
||||
Reference in New Issue
Block a user