mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
fix(proxy): harden AGY risk persistence and confirm UX
- add lock-held mutateUnifiedConfig read-modify-write path for config.yaml - use atomic mutation in /api/settings/auth/antigravity-risk to prevent stale overwrites - replace browser confirm with in-app 2-step banner + typed phrase validation - verify saved AGY power-user value with post-save read before showing success Refs #509
This commit is contained in:
@@ -719,14 +719,24 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save unified config to YAML file.
|
* Sync sleep helper for lock retry loops.
|
||||||
* Uses atomic write (temp file + rename) to prevent corruption.
|
* Uses Atomics.wait when available to avoid CPU-intensive busy-wait.
|
||||||
* Uses lockfile to prevent concurrent writes.
|
|
||||||
*/
|
*/
|
||||||
export function saveUnifiedConfig(config: UnifiedConfig): void {
|
function sleepSync(ms: number): void {
|
||||||
const yamlPath = getConfigYamlPath();
|
try {
|
||||||
const dir = path.dirname(yamlPath);
|
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)
|
// Acquire lock (retry for up to 1 second)
|
||||||
const maxRetries = 10;
|
const maxRetries = 10;
|
||||||
const retryDelayMs = 100;
|
const retryDelayMs = 100;
|
||||||
@@ -736,18 +746,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
|
|||||||
lockAcquired = true;
|
lockAcquired = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Synchronous sleep without CPU-intensive busy-wait
|
sleepSync(retryDelayMs);
|
||||||
// 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 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lockAcquired) {
|
if (!lockAcquired) {
|
||||||
@@ -755,57 +754,112 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure directory exists
|
return callback();
|
||||||
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;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
// Always release lock
|
// Always release lock
|
||||||
releaseLock();
|
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.
|
* Update unified config with partial data.
|
||||||
* Loads existing config, merges changes, and saves.
|
* Loads existing config, merges changes, and saves.
|
||||||
*/
|
*/
|
||||||
export function updateUnifiedConfig(updates: Partial<UnifiedConfig>): UnifiedConfig {
|
export function updateUnifiedConfig(updates: Partial<UnifiedConfig>): UnifiedConfig {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
return mutateUnifiedConfig((config) => {
|
||||||
const updated = { ...config, ...updates };
|
Object.assign(config, updates);
|
||||||
saveUnifiedConfig(updated);
|
});
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
|||||||
import {
|
import {
|
||||||
getDashboardAuthConfig,
|
getDashboardAuthConfig,
|
||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
saveUnifiedConfig,
|
mutateUnifiedConfig,
|
||||||
} from '../../config/unified-config-loader';
|
} from '../../config/unified-config-loader';
|
||||||
import type { Settings } from '../../types/config';
|
import type { Settings } from '../../types/config';
|
||||||
|
|
||||||
@@ -487,16 +487,16 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const updatedConfig = mutateUnifiedConfig((config) => {
|
||||||
config.cliproxy.safety = {
|
config.cliproxy.safety = {
|
||||||
...(config.cliproxy.safety ?? {}),
|
...(config.cliproxy.safety ?? {}),
|
||||||
antigravity_ack_bypass: antigravityAckBypass,
|
antigravity_ack_bypass: antigravityAckBypass,
|
||||||
};
|
};
|
||||||
saveUnifiedConfig(config);
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
antigravityAckBypass,
|
antigravityAckBypass: updatedConfig.cliproxy?.safety?.antigravity_ack_bypass === true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const classified = classifyConfigSaveFailure(error);
|
const classified = classifyConfigSaveFailure(error);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useProxyConfig, useRawConfig } from '../../hooks';
|
import { useProxyConfig, useRawConfig } from '../../hooks';
|
||||||
import { useUpdateBackend, useProxyStatus } from '@/hooks/use-cliproxy';
|
import { useUpdateBackend, useProxyStatus } from '@/hooks/use-cliproxy';
|
||||||
@@ -26,6 +28,7 @@ import { RemoteProxyCard } from './remote-proxy-card';
|
|||||||
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
|
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/** LocalStorage key for debug mode preference */
|
/** LocalStorage key for debug mode preference */
|
||||||
@@ -34,6 +37,10 @@ const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
|||||||
/** Providers only available on CLIProxyAPIPlus */
|
/** Providers only available on CLIProxyAPIPlus */
|
||||||
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
|
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
|
||||||
|
|
||||||
|
function normalizeRiskAckPhrase(value: string): string {
|
||||||
|
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProxySection() {
|
export default function ProxySection() {
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
@@ -71,7 +78,11 @@ export default function ProxySection() {
|
|||||||
const [agyAckBypass, setAgyAckBypass] = useState(false);
|
const [agyAckBypass, setAgyAckBypass] = useState(false);
|
||||||
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true);
|
const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true);
|
||||||
const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false);
|
const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false);
|
||||||
|
const [showAgyEnableConfirm, setShowAgyEnableConfirm] = useState(false);
|
||||||
|
const [agyEnableConfirmPhrase, setAgyEnableConfirmPhrase] = useState('');
|
||||||
const agyAckBypassSavingRef = useRef(false);
|
const agyAckBypassSavingRef = useRef(false);
|
||||||
|
const isAgyConfirmPhraseValid =
|
||||||
|
normalizeRiskAckPhrase(agyEnableConfirmPhrase) === RISK_ACK_PHRASE;
|
||||||
|
|
||||||
const handleDebugModeChange = (enabled: boolean) => {
|
const handleDebugModeChange = (enabled: boolean) => {
|
||||||
setDebugMode(enabled);
|
setDebugMode(enabled);
|
||||||
@@ -99,17 +110,10 @@ export default function ProxySection() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveAgyAckBypass = useCallback(
|
const persistAgyAckBypass = useCallback(
|
||||||
async (nextValue: boolean) => {
|
async (nextValue: boolean) => {
|
||||||
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
|
if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return;
|
||||||
|
|
||||||
if (nextValue) {
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Enable AGY power user mode?\n\nThis skips AGY safety prompts. You accept the OAuth risk.'
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agyAckBypassSavingRef.current = true;
|
agyAckBypassSavingRef.current = true;
|
||||||
setAgyAckBypassSaving(true);
|
setAgyAckBypassSaving(true);
|
||||||
@@ -120,12 +124,34 @@ export default function ProxySection() {
|
|||||||
body: JSON.stringify({ antigravityAckBypass: nextValue }),
|
body: JSON.stringify({ antigravityAckBypass: nextValue }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
antigravityAckBypass?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = (await response.json()) as { error?: string };
|
throw new Error(payload.error || 'Failed to update AGY power user mode');
|
||||||
throw new Error(data.error || 'Failed to update AGY power user mode');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAgyAckBypass(nextValue);
|
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.');
|
toast.success(nextValue ? 'AGY power user mode enabled.' : 'AGY power user mode disabled.');
|
||||||
await fetchRawConfig();
|
await fetchRawConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -138,6 +164,30 @@ export default function ProxySection() {
|
|||||||
[agyAckBypassSaving, fetchRawConfig, saving]
|
[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
|
// Backend state (loaded from API) + mutation hook for proper query invalidation
|
||||||
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
||||||
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
|
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
|
||||||
@@ -484,7 +534,7 @@ export default function ProxySection() {
|
|||||||
aria-describedby="agy-power-user-mode-description"
|
aria-describedby="agy-power-user-mode-description"
|
||||||
checked={agyAckBypass}
|
checked={agyAckBypass}
|
||||||
disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
|
disabled={agyAckBypassLoading || agyAckBypassSaving || saving}
|
||||||
onCheckedChange={saveAgyAckBypass}
|
onCheckedChange={handleAgyAckBypassChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -494,6 +544,81 @@ export default function ProxySection() {
|
|||||||
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
|
Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS
|
||||||
cannot assume responsibility for account loss.
|
cannot assume responsibility for account loss.
|
||||||
</p>
|
</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">
|
<span id="agy-power-user-mode-label" className="sr-only">
|
||||||
Toggle AGY power user mode
|
Toggle AGY power user mode
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user