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

fix(agy): enforce multi-step responsibility acknowledgement for antigravity oauth
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-26 01:44:43 -05:00
commit 4f1aa69d01
19 files changed
+1465 -167

No files matched your search

@@ -1,24 +1,45 @@
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { AlertTriangle, ExternalLink, Settings2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
interface AccountSafetyWarningCardProps {
className?: string;
showAcknowledgement?: boolean;
acknowledged?: boolean;
onAcknowledgedChange?: (value: boolean) => void;
acknowledgementPhrase?: string;
acknowledgementText?: string;
onAcknowledgementTextChange?: (value: string) => void;
disabled?: boolean;
showProxySettingsLink?: boolean;
}
export function AccountSafetyWarningCard({
className,
showAcknowledgement = false,
acknowledged = false,
onAcknowledgedChange,
acknowledgementPhrase = RISK_ACK_PHRASE,
acknowledgementText = '',
onAcknowledgementTextChange,
disabled = false,
showProxySettingsLink = false,
}: AccountSafetyWarningCardProps) {
const title = 'OAuth Account Safety Warning';
const subtitle = 'Issue #509 · Gemini + AGY OAuth risk';
const firstLine = (
<>
Issue #509 documents suspension/ban reports tied to <code className="font-mono">ccs agy</code>{' '}
and shared-account usage between <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code>.
</>
);
const secondLine = (
<>Continue only if you accept full responsibility for OAuth and account-access risk.</>
);
const issueUrl = 'https://github.com/kaitranntt/ccs/issues/509';
const issueLabel = 'Read issue #509';
const proxySettingsLabel = 'Gemini + AGY controls: Settings > Proxy';
return (
<section
role="alert"
@@ -36,10 +57,8 @@ export function AccountSafetyWarningCard({
<AlertTriangle className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold leading-5">Account Safety Warning</p>
<p className="text-xs text-muted-foreground">
Issue #509 · Shared Gemini + AGY account risk
</p>
<p className="text-sm font-semibold leading-5">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
</div>
<Badge
@@ -51,13 +70,8 @@ export function AccountSafetyWarningCard({
</div>
<div className="space-y-2 text-sm leading-relaxed">
<p>
Using one Google account for both <code className="font-mono">ccs gemini</code> and{' '}
<code className="font-mono">ccs agy</code> can trigger account disable/ban.
</p>
<p className="font-medium text-amber-900 dark:text-amber-200">
If you want to keep Google AI access, do not continue this shared-account setup.
</p>
<p>{firstLine}</p>
<p className="font-medium text-amber-900 dark:text-amber-200">{secondLine}</p>
<p className="text-xs text-muted-foreground">
CCS is provided as-is and does not take responsibility for suspension, bans, or access
loss from upstream providers.
@@ -66,33 +80,44 @@ export function AccountSafetyWarningCard({
<div className="flex flex-wrap items-center gap-2">
<a
href="https://github.com/kaitranntt/ccs/issues/509"
href={issueUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
>
Read issue #509
{issueLabel}
<ExternalLink className="h-3.5 w-3.5" />
</a>
{showProxySettingsLink && (
<a
href="/settings?tab=proxy"
className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-800 transition-colors hover:bg-amber-500/15 dark:text-amber-200"
>
<Settings2 className="h-3.5 w-3.5" />
{proxySettingsLabel}
</a>
)}
<span className="rounded-md border border-border/70 bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground">
Applies to CLI and dashboard auth
</span>
</div>
{showAcknowledgement && onAcknowledgedChange && (
{showAcknowledgement && onAcknowledgementTextChange && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 p-2.5">
<div className="flex items-start gap-2">
<Checkbox
id="account-risk-ack"
checked={acknowledged}
onCheckedChange={(checked) => onAcknowledgedChange(Boolean(checked))}
disabled={disabled}
/>
<Label htmlFor="account-risk-ack" className="text-xs leading-5">
I understand this risk and that CCS takes no responsibility if I continue this
setup.
</Label>
</div>
<Label htmlFor="account-risk-ack-text" className="text-xs leading-5">
Type exact phrase to continue:{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono">
{acknowledgementPhrase}
</code>
</Label>
<Input
id="account-risk-ack-text"
value={acknowledgementText}
onChange={(e) => onAcknowledgementTextChange(e.target.value)}
placeholder={acknowledgementPhrase}
disabled={disabled}
className="mt-2 font-mono text-xs"
/>
</div>
)}
</div>
+162 -12
View File
@@ -7,7 +7,7 @@
* For Kiro: Also shows "Import from IDE" option.
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -25,11 +25,18 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react';
import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from 'lucide-react';
import { useKiroImport } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { applyDefaultPreset } from '@/lib/preset-utils';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
import {
ANTIGRAVITY_ACK_VERSION,
DEFAULT_ANTIGRAVITY_RISK_CHECKLIST,
RISK_ACK_PHRASE,
isAntigravityRiskChecklistComplete,
} from '@/components/account/antigravity-responsibility-constants';
import {
DEFAULT_KIRO_AUTH_METHOD,
getKiroAuthMethodOption,
@@ -49,6 +56,10 @@ interface AddAccountDialogProps {
isFirstAccount?: boolean;
}
function normalizeRiskPhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
}
export function AddAccountDialog({
open,
onClose,
@@ -60,14 +71,21 @@ export function AddAccountDialog({
const [callbackUrl, setCallbackUrl] = useState('');
const [copied, setCopied] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [acknowledgedRisk, setAcknowledgedRisk] = useState(false);
const [riskAcknowledgementText, setRiskAcknowledgementText] = useState('');
const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false);
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
const wasAuthenticatingRef = useRef(false);
const authFlow = useCliproxyAuthFlow();
const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro';
const requiresSafetyAcknowledgement = provider === 'gemini' || provider === 'agy';
const requiresSafetyAcknowledgement = provider === 'gemini';
const requiresAgyResponsibilityFlow = provider === 'agy' && !agyAckBypassEnabled;
const isAgyBypassStatePending = provider === 'agy' && agyAckBypassLoading;
const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist);
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
const defaultDeviceCode = isDeviceCodeProvider(provider);
const requiresNickname = isNicknameRequiredProvider(provider);
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
@@ -76,12 +94,24 @@ export function AddAccountDialog({
const nicknameTrimmed = nickname.trim();
const errorMessage = localError || authFlow.error;
const fetchAgyBypassState = useCallback(async (): Promise<boolean> => {
const response = await fetch('/api/settings/auth/antigravity-risk');
if (!response.ok) {
throw new Error('Failed to load Antigravity power user setting');
}
const data = (await response.json()) as { antigravityAckBypass?: boolean };
return data.antigravityAckBypass === true;
}, []);
const resetAndClose = () => {
setNickname('');
setCallbackUrl('');
setCopied(false);
setLocalError(null);
setAcknowledgedRisk(false);
setRiskAcknowledgementText('');
setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
setAgyAckBypassEnabled(false);
setAgyAckBypassLoading(false);
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
wasAuthenticatingRef.current = false;
onClose();
@@ -89,11 +119,87 @@ export function AddAccountDialog({
useEffect(() => {
if (open) {
setAcknowledgedRisk(false);
setRiskAcknowledgementText('');
setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST);
setLocalError(null);
}
}, [provider, open]);
useEffect(() => {
let cancelled = false;
if (!open || provider !== 'agy') {
setAgyAckBypassEnabled(false);
setAgyAckBypassLoading(false);
return;
}
const loadAgyBypassState = async () => {
try {
setAgyAckBypassLoading(true);
const enabled = await fetchAgyBypassState();
if (!cancelled) {
setAgyAckBypassEnabled(enabled);
}
} catch {
if (!cancelled) {
setAgyAckBypassEnabled(false);
}
} finally {
if (!cancelled) {
setAgyAckBypassLoading(false);
}
}
};
loadAgyBypassState();
return () => {
cancelled = true;
};
}, [fetchAgyBypassState, open, provider]);
useEffect(() => {
if (!open || provider !== 'agy' || !authFlow.error || !agyAckBypassEnabled) {
return;
}
const normalizedError = authFlow.error.toLowerCase();
const ackRequired =
normalizedError.includes('agy_risk_ack_required') ||
normalizedError.includes('responsibility acknowledgement') ||
normalizedError.includes('responsibility checklist');
if (!ackRequired) return;
let cancelled = false;
const syncBypassState = async () => {
try {
setAgyAckBypassLoading(true);
const enabled = await fetchAgyBypassState();
if (cancelled) return;
setAgyAckBypassEnabled(enabled);
if (!enabled) {
setLocalError('Power user mode is off. Complete the AGY checklist and retry.');
}
} catch {
if (cancelled) return;
setAgyAckBypassEnabled(false);
setLocalError('Power user mode is off. Complete the AGY checklist and retry.');
} finally {
if (!cancelled) {
setAgyAckBypassLoading(false);
}
}
};
void syncBypassState();
return () => {
cancelled = true;
};
}, [agyAckBypassEnabled, authFlow.error, fetchAgyBypassState, open, provider]);
// When authFlow completes successfully (polling detected success), apply preset and close
useEffect(() => {
if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) {
@@ -142,9 +248,19 @@ export function AddAccountDialog({
* - Authorization code providers use /start-url and polling.
*/
const handleAuthenticate = () => {
if (requiresSafetyAcknowledgement && !acknowledgedRisk) {
if (isAgyBypassStatePending) {
setLocalError('Loading Antigravity safety settings. Please wait a moment and retry.');
return;
}
if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) {
setLocalError(
'Please acknowledge the account safety warning before authenticating this provider.'
'Complete all Antigravity responsibility steps before authenticating this provider.'
);
return;
}
if (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) {
setLocalError(
`Type "${RISK_ACK_PHRASE}" to acknowledge the account safety warning before authenticating this provider.`
);
return;
}
@@ -159,6 +275,15 @@ export function AddAccountDialog({
kiroMethod: isKiro ? kiroAuthMethod : undefined,
flowType: isKiro ? kiroMethodOption.flowType : undefined,
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
riskAcknowledgement: requiresAgyResponsibilityFlow
? {
version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue509: agyRiskChecklist.reviewedIssue509,
understandsBanRisk: agyRiskChecklist.understandsBanRisk,
acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility,
typedPhrase: agyRiskChecklist.typedPhrase,
}
: undefined,
});
};
@@ -204,12 +329,35 @@ export function AddAccountDialog({
</DialogHeader>
<div className="space-y-4 py-4">
{requiresAgyResponsibilityFlow && !showAuthUI && (
<AntigravityResponsibilityChecklist
value={agyRiskChecklist}
onChange={(value) => {
setAgyRiskChecklist(value);
setLocalError(null);
}}
disabled={isPending}
/>
)}
{provider === 'agy' && agyAckBypassEnabled && !showAuthUI && (
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" />
Power user mode enabled
</div>
AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full
responsibility for OAuth/account risk.
</div>
)}
{requiresSafetyAcknowledgement && !showAuthUI && (
<AccountSafetyWarningCard
showAcknowledgement
acknowledged={acknowledgedRisk}
onAcknowledgedChange={(value) => {
setAcknowledgedRisk(value);
acknowledgementPhrase={RISK_ACK_PHRASE}
acknowledgementText={riskAcknowledgementText}
onAcknowledgementTextChange={(value) => {
setRiskAcknowledgementText(value);
setLocalError(null);
}}
disabled={isPending}
@@ -405,8 +553,10 @@ export function AddAccountDialog({
onClick={handleAuthenticate}
disabled={
isPending ||
isAgyBypassStatePending ||
(requiresNickname && !nicknameTrimmed) ||
(requiresSafetyAcknowledgement && !acknowledgedRisk)
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
}
>
<ExternalLink className="w-4 h-4 mr-2" />
@@ -0,0 +1,143 @@
import { AlertTriangle, ExternalLink, ShieldAlert } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants';
interface AntigravityResponsibilityChecklistProps {
className?: string;
value: AntigravityRiskChecklistValue;
onChange: (value: AntigravityRiskChecklistValue) => void;
disabled?: boolean;
}
export function AntigravityResponsibilityChecklist({
className,
value,
onChange,
disabled = false,
}: AntigravityResponsibilityChecklistProps) {
const completedSteps = [
value.reviewedIssue509,
value.understandsBanRisk,
value.acceptsFullResponsibility,
value.typedPhrase.trim().replace(/\s+/g, ' ').toUpperCase() === ANTIGRAVITY_ACK_PHRASE,
].filter(Boolean).length;
const progressValue = (completedSteps / 4) * 100;
const setValue = (next: Partial<AntigravityRiskChecklistValue>) => {
onChange({ ...value, ...next });
};
return (
<section
role="alert"
className={cn(
'relative overflow-hidden rounded-xl border border-rose-500/35 bg-gradient-to-br from-rose-50 via-background to-amber-50/70 p-4 shadow-sm dark:from-rose-950/20 dark:to-amber-950/20',
className
)}
>
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-rose-500 via-orange-500 to-amber-500" />
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-md bg-rose-500/15 text-rose-700 dark:text-rose-300">
<AlertTriangle className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-5">Antigravity OAuth Responsibility</p>
<p className="text-xs text-muted-foreground">
Complete all 4 steps before you can authenticate.
</p>
</div>
</div>
<Badge variant="outline" className="border-rose-500/40 text-rose-700 dark:text-rose-300">
Mandatory
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Completion</span>
<span>{completedSteps}/4 steps</span>
</div>
<Progress value={progressValue} className="h-2" />
</div>
<div className="space-y-3 rounded-lg border border-rose-500/20 bg-rose-500/5 p-3">
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-reviewed-issue"
checked={value.reviewedIssue509}
onCheckedChange={(checked) => setValue({ reviewedIssue509: Boolean(checked) })}
disabled={disabled}
/>
<Label htmlFor="agy-step-reviewed-issue" className="text-xs leading-5">
Step 1: I reviewed issue #509 and understand AGY OAuth can trigger account
bans/suspensions.
</Label>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-understands-risk"
checked={value.understandsBanRisk}
onCheckedChange={(checked) => setValue({ understandsBanRisk: Boolean(checked) })}
disabled={disabled}
/>
<Label htmlFor="agy-step-understands-risk" className="text-xs leading-5">
Step 2: I understand this OAuth action is my own decision and I accept the upstream
risk.
</Label>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="agy-step-accept-responsibility"
checked={value.acceptsFullResponsibility}
onCheckedChange={(checked) =>
setValue({ acceptsFullResponsibility: Boolean(checked) })
}
disabled={disabled}
/>
<Label htmlFor="agy-step-accept-responsibility" className="text-xs leading-5">
Step 3: I accept full responsibility. CCS is not liable for suspension, bans, or
access loss.
</Label>
</div>
</div>
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-amber-800 dark:text-amber-200">
<ShieldAlert className="h-3.5 w-3.5" />
Step 4: Type exact phrase to continue
</div>
<Input
value={value.typedPhrase}
onChange={(e) => setValue({ typedPhrase: e.target.value })}
placeholder={ANTIGRAVITY_ACK_PHRASE}
disabled={disabled}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<a
href="https://github.com/kaitranntt/ccs/issues/509"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-2.5 py-1 font-medium text-rose-800 transition-colors hover:bg-rose-500/15 dark:text-rose-200"
>
Read issue #509
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>
);
}
@@ -0,0 +1,26 @@
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2';
export const RISK_ACK_PHRASE = 'I ACCEPT RISK';
export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE;
export interface AntigravityRiskChecklistValue {
reviewedIssue509: boolean;
understandsBanRisk: boolean;
acceptsFullResponsibility: boolean;
typedPhrase: string;
}
export const DEFAULT_ANTIGRAVITY_RISK_CHECKLIST: AntigravityRiskChecklistValue = {
reviewedIssue509: false,
understandsBanRisk: false,
acceptsFullResponsibility: false,
typedPhrase: '',
};
export function isAntigravityRiskChecklistComplete(value: AntigravityRiskChecklistValue): boolean {
return (
value.reviewedIssue509 &&
value.understandsBanRisk &&
value.acceptsFullResponsibility &&
value.typedPhrase.trim().replace(/\s+/g, ' ').toUpperCase() === ANTIGRAVITY_ACK_PHRASE
);
}
+46 -15
View File
@@ -28,6 +28,13 @@ interface StartAuthOptions {
kiroMethod?: string;
flowType?: 'authorization_code' | 'device_code';
startEndpoint?: 'start' | 'start-url';
riskAcknowledgement?: {
version: string;
reviewedIssue509: boolean;
understandsBanRisk: boolean;
acceptsFullResponsibility: boolean;
typedPhrase: string;
};
}
/** Polling interval for OAuth status check (3 seconds) */
@@ -35,6 +42,19 @@ const POLL_INTERVAL = 3000;
/** Maximum polling duration (5 minutes) */
const MAX_POLL_DURATION = 5 * 60 * 1000;
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
const text = await response.text();
if (!text) return {};
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
const fallbackError =
response.status >= 400 ? `Request failed with status ${response.status}` : undefined;
return fallbackError ? { error: fallbackError } : {};
}
}
/** Initial state for auth flow - extracted for DRY */
const INITIAL_STATE: AuthFlowState = {
provider: null,
@@ -175,6 +195,7 @@ export function useCliproxyAuthFlow() {
const payload = {
nickname: options?.nickname,
kiroMethod: options?.kiroMethod,
riskAcknowledgement: options?.riskAcknowledgement,
};
setState({
@@ -198,8 +219,9 @@ export function useCliproxyAuthFlow() {
signal: controller.signal,
})
.then(async (response) => {
const data = await response.json();
if (response.ok && data.success) {
const data = await parseResponseBody(response);
const success = data.success === true;
if (response.ok && success) {
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
// Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast
@@ -207,7 +229,8 @@ export function useCliproxyAuthFlow() {
openedAuthUrlRef.current = false;
setState(INITIAL_STATE);
} else {
const errorMsg = data.error || 'Authentication failed';
const errorMsg =
typeof data.error === 'string' ? data.error : 'Authentication failed';
toast.error(errorMsg);
setState((prev) => ({
...prev,
@@ -239,30 +262,35 @@ export function useCliproxyAuthFlow() {
signal: controller.signal,
});
const data = await response.json();
const data = await parseResponseBody(response);
const success = data.success === true;
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to start OAuth');
if (!response.ok || !success) {
const errorMsg = typeof data.error === 'string' ? data.error : 'Failed to start OAuth';
throw new Error(errorMsg);
}
const authUrl = typeof data.authUrl === 'string' ? data.authUrl : null;
const oauthState = typeof data.state === 'string' ? data.state : null;
// Update state with auth URL
setState((prev) => ({
...prev,
authUrl: data.authUrl || null,
oauthState: data.state,
authUrl,
oauthState,
}));
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
if (data.authUrl) {
if (authUrl) {
openedAuthUrlRef.current = true;
window.open(data.authUrl, '_blank');
window.open(authUrl, '_blank');
}
// Start polling for completion
if (data.state) {
if (oauthState) {
pollStartRef.current = Date.now();
pollIntervalRef.current = setInterval(() => {
pollStatus(provider, data.state);
pollStatus(provider, oauthState);
}, POLL_INTERVAL);
}
}
@@ -311,16 +339,19 @@ export function useCliproxyAuthFlow() {
body: JSON.stringify({ redirectUrl }),
});
const data = await response.json();
const data = await parseResponseBody(response);
const success = data.success === true;
if (response.ok && data.success) {
if (response.ok && success) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
toast.success(`${state.provider} authentication successful`);
setState(INITIAL_STATE);
} else {
throw new Error(data.error || 'Callback submission failed');
const errorMsg =
typeof data.error === 'string' ? data.error : 'Callback submission failed';
throw new Error(errorMsg);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to submit callback';
+3 -1
View File
@@ -397,7 +397,9 @@ export function CliproxyPage() {
{/* Right Panel */}
<div className="flex-1 flex flex-col min-w-0 bg-background">
{showAccountSafetyWarning && <AccountSafetyWarningCard className="mx-4 mt-4" />}
{showAccountSafetyWarning && (
<AccountSafetyWarningCard showProxySettingsLink className="mx-4 mt-4" />
)}
{selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name
@@ -197,6 +197,13 @@ export default function AuthSection() {
setTimeout(() => setCopiedSecret(false), 2000);
};
const refreshAll = async () => {
if (loading || saving) return;
setError(null);
setSuccess(null);
await Promise.all([fetchTokens(), fetchRawConfig()]);
};
if (loading || !tokens) {
return (
<div className="flex-1 flex items-center justify-center">
@@ -359,7 +366,6 @@ export default function AuthSection() {
</div>
</div>
{/* Actions */}
<div className="pt-4 border-t">
<Button
variant="outline"
@@ -383,10 +389,7 @@ export default function AuthSection() {
<Button
variant="outline"
size="sm"
onClick={() => {
fetchTokens();
fetchRawConfig();
}}
onClick={refreshAll}
disabled={loading || saving}
className="flex-1"
>
+227 -4
View File
@@ -3,9 +3,10 @@
* Settings section for CLIProxyAPI configuration (local/remote)
*/
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import {
@@ -17,6 +18,8 @@ import {
Bug,
Box,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from 'lucide-react';
import { useProxyConfig, useRawConfig } from '../../hooks';
import { useUpdateBackend, useProxyStatus } from '@/hooks/use-cliproxy';
@@ -25,6 +28,7 @@ import { RemoteProxyCard } from './remote-proxy-card';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import { toast } from 'sonner';
/** LocalStorage key for debug mode preference */
@@ -33,6 +37,10 @@ const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
function normalizeRiskAckPhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
}
export default function ProxySection() {
const {
config,
@@ -67,6 +75,14 @@ export default function ProxySection() {
return false;
}
});
const [agyAckBypass, setAgyAckBypass] = useState(false);
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true);
const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false);
const [showAgyEnableConfirm, setShowAgyEnableConfirm] = useState(false);
const [agyEnableConfirmPhrase, setAgyEnableConfirmPhrase] = useState('');
const agyAckBypassSavingRef = useRef(false);
const isAgyConfirmPhraseValid =
normalizeRiskAckPhrase(agyEnableConfirmPhrase) === RISK_ACK_PHRASE;
const handleDebugModeChange = (enabled: boolean) => {
setDebugMode(enabled);
@@ -77,6 +93,101 @@ export default function ProxySection() {
}
};
const fetchAgyAckBypass = useCallback(async () => {
try {
setAgyAckBypassLoading(true);
const response = await fetch('/api/settings/auth/antigravity-risk');
if (!response.ok) {
throw new Error('Failed to load AGY power user mode');
}
const data = (await response.json()) as { antigravityAckBypass?: boolean };
setAgyAckBypass(data.antigravityAckBypass === true);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load AGY power user mode');
setAgyAckBypass(false);
} finally {
setAgyAckBypassLoading(false);
}
}, []);
const persistAgyAckBypass = useCallback(
async (nextValue: boolean) => {
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
try {
agyAckBypassSavingRef.current = true;
setAgyAckBypassSaving(true);
const response = await fetch('/api/settings/auth/antigravity-risk', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ antigravityAckBypass: nextValue }),
});
const payload = (await response.json()) as {
antigravityAckBypass?: boolean;
error?: string;
};
if (!response.ok) {
throw new Error(payload.error || 'Failed to update AGY power user mode');
}
const persistedValue = payload.antigravityAckBypass === true;
const verifyResponse = await fetch('/api/settings/auth/antigravity-risk', {
cache: 'no-store',
});
if (!verifyResponse.ok) {
throw new Error('Failed to verify AGY power user mode persistence');
}
const verifyData = (await verifyResponse.json()) as { antigravityAckBypass?: boolean };
const verifiedValue = verifyData.antigravityAckBypass === true;
if (verifiedValue !== nextValue) {
throw new Error(
'AGY power user mode was not persisted. Config may have been modified by another process.'
);
}
setAgyAckBypass(verifiedValue && persistedValue);
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.');
await fetchRawConfig();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update AGY power user mode');
} finally {
agyAckBypassSavingRef.current = false;
setAgyAckBypassSaving(false);
}
},
[agyAckBypassSaving, fetchRawConfig, saving]
);
const handleAgyAckBypassChange = useCallback(
(nextValue: boolean) => {
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
if (nextValue) {
setShowAgyEnableConfirm(true);
return;
}
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
void persistAgyAckBypass(false);
},
[agyAckBypassSaving, persistAgyAckBypass, saving]
);
const confirmAgyEnable = useCallback(() => {
if (!isAgyConfirmPhraseValid) {
toast.error(`Type "${RISK_ACK_PHRASE}" to continue.`);
return;
}
void persistAgyAckBypass(true);
}, [isAgyConfirmPhraseValid, persistAgyAckBypass]);
// Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
@@ -138,12 +249,13 @@ export default function ProxySection() {
// Load data on mount
useEffect(() => {
fetchConfig();
void fetchAgyAckBypass();
fetchRawConfig();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Async data fetching on mount is intended
void fetchBackend();
void checkPlusOnlyVariants();
}, [fetchConfig, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
}, [fetchConfig, fetchAgyAckBypass, fetchRawConfig, fetchBackend, checkPlusOnlyVariants]);
if (loading || !config) {
return (
@@ -403,6 +515,116 @@ export default function ProxySection() {
)}
</div>
{/* Safety */}
<div className="space-y-3">
<h3 className="text-base font-medium flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-amber-700 dark:text-amber-300" />
Safety
</h3>
<div className="space-y-3 rounded-lg border border-amber-400/35 bg-amber-50/70 p-4 dark:border-amber-800/60 dark:bg-amber-950/25">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="font-medium text-sm">Antigravity Power User Mode</p>
<p className="text-xs text-muted-foreground">
Skip AGY responsibility checklist in Add Account and `ccs agy` flows.
</p>
</div>
<Switch
aria-labelledby="agy-power-user-mode-label"
aria-describedby="agy-power-user-mode-description"
checked={agyAckBypass}
disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
onCheckedChange={handleAgyAckBypassChange}
/>
</div>
<p
id="agy-power-user-mode-description"
className="text-xs text-amber-800/90 dark:text-amber-200/90"
>
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
cannot assume responsibility for account loss.
</p>
{showAgyEnableConfirm && (
<div className="space-y-3 rounded-lg border border-rose-500/40 bg-rose-500/[0.08] p-3.5">
<div className="space-y-1.5">
<p className="text-xs font-semibold tracking-wide text-rose-900 dark:text-rose-200">
Final confirmation required
</p>
<p className="text-xs leading-relaxed text-rose-800/95 dark:text-rose-200/90">
Enabling this will skip AGY safety checkpoints in both dashboard and CLI.
Review issue #509 and type the exact phrase to proceed.
</p>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 1
</p>
<a
href="https://github.com/kaitranntt/ccs/issues/509"
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-rose-800 underline decoration-rose-500/60 underline-offset-2 transition-colors hover:text-rose-700 dark:text-rose-200"
>
Read issue #509
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="rounded-md border border-rose-400/30 bg-rose-500/10 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-rose-900 dark:text-rose-200">
Step 2
</p>
<p className="mt-1 text-xs text-rose-800/95 dark:text-rose-200/90">
Type{' '}
<code className="rounded bg-background/80 px-1 py-0.5 font-mono">
{RISK_ACK_PHRASE}
</code>{' '}
to enable.
</p>
</div>
</div>
<div className="space-y-2">
<Input
value={agyEnableConfirmPhrase}
onChange={(e) => setAgyEnableConfirmPhrase(e.target.value)}
placeholder={RISK_ACK_PHRASE}
disabled={agyAckBypassSaving || saving}
className="font-mono text-xs"
aria-label="Type I ACCEPT RISK to enable Antigravity power user mode"
/>
<p className="text-[11px] text-rose-800/90 dark:text-rose-200/80">
Exact phrase required.
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setShowAgyEnableConfirm(false);
setAgyEnableConfirmPhrase('');
}}
disabled={agyAckBypassSaving || saving}
>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
onClick={confirmAgyEnable}
disabled={!isAgyConfirmPhraseValid || agyAckBypassSaving || saving}
>
Enable Power User Mode
</Button>
</div>
</div>
)}
<span id="agy-power-user-mode-label" className="sr-only">
Toggle AGY power user mode
</span>
</div>
</div>
{/* Remote Settings - Show when remote mode is enabled */}
{isRemoteMode && (
<RemoteProxyCard
@@ -517,11 +739,12 @@ export default function ProxySection() {
size="sm"
onClick={() => {
fetchConfig();
fetchAgyAckBypass();
fetchRawConfig();
fetchBackend();
checkPlusOnlyVariants();
}}
disabled={loading || saving}
disabled={loading || saving || agyAckBypassSaving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />