diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index c3f2f3bb..ed24cd88 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -529,9 +529,23 @@ export async function execClaudeWithCLIProxy( log(`Using remote proxy authentication (skipping local OAuth)`); } - if (provider === 'agy' && !forceAuth && !skipLocalAuth) { + if (provider === 'agy' && forceAuth && skipLocalAuth) { + const acknowledged = await ensureCliAntigravityResponsibility({ + context: 'oauth', + acceptedByFlag: acceptAgyRisk, + }); + if (!acknowledged) { + throw new Error( + `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` + ); + } + console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); + return; + } + + if (provider === 'agy' && !forceAuth) { const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider); - if (!requiresAuthNow) { + if (skipLocalAuth || !requiresAuthNow) { const acknowledged = await ensureCliAntigravityResponsibility({ context: 'run', acceptedByFlag: acceptAgyRisk, diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 29f6ccef..83cda87e 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -243,6 +243,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { accounts: partial.accounts ?? defaults.accounts, profiles: partial.profiles ?? defaults.profiles, cliproxy: { + ...partial.cliproxy, oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts, providers: defaults.cliproxy.providers, // Always use defaults for providers variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants, @@ -256,8 +257,12 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.cliproxy?.safety?.antigravity_ack_bypass ?? DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, }, + // Kiro browser behavior setting (optional) + kiro_no_incognito: partial.cliproxy?.kiro_no_incognito, // Auth config - preserve user values, no defaults (uses constants as fallback) auth: partial.cliproxy?.auth, + // Background token refresh config (optional) + token_refresh: partial.cliproxy?.token_refresh, // Backend selection - validate and preserve user choice (original vs plus) backend: partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 6033f58f..940f9e7d 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -56,6 +56,24 @@ const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`); + return; + } + console.error(`[cliproxy-auth-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -167,12 +185,12 @@ router.get('/', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote auth status', authStatus: [], source: 'remote', }); } else { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to fetch auth status.'); } } }); @@ -202,13 +220,12 @@ router.get('/accounts', async (_req: Request, res: Response): Promise => { const target = getProxyTarget(); if (target.isRemote) { res.status(503).json({ - error: (error as Error).message, + error: 'Failed to fetch remote account status', accounts: [], source: 'remote', }); } else { - const message = error instanceof Error ? error.message : 'Failed to list accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to list accounts.'); } } }); @@ -229,8 +246,7 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { const accounts = getProviderAccounts(provider as CLIProxyProvider); res.json({ provider, accounts }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to get provider accounts'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to get provider accounts.'); } }); @@ -272,8 +288,7 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to set default account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to set default account.'); } }); @@ -309,8 +324,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to remove account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to remove account.'); } }); @@ -342,8 +356,7 @@ router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to pause account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to pause account.'); } }); @@ -374,8 +387,7 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to resume account'; - res.status(500).json({ error: message }); + respondInternalError(res, error, 'Failed to resume account.'); } }); @@ -385,14 +397,20 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { - nickname: nicknameRaw, - noIncognito: noIncognitoBody, - kiroMethod: kiroMethodRaw, - riskAcknowledgement, - } = req.body; + const requestBody = + req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const noIncognitoBody = + typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined; + const kiroMethodRaw = requestBody.kiroMethod; + const riskAcknowledgement = requestBody.riskAcknowledgement; + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ error: 'OAuth start flow not available in remote mode' }); + return; + } // Trim nickname for consistency with CLI (oauth-handler.ts trims input) - const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; + const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Validate provider @@ -488,7 +506,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise }); } } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to import Kiro token.'); } }); @@ -700,8 +718,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method: data.method || null, }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to start OAuth'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); @@ -815,8 +832,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P res.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to submit callback'; - res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index bbcf8830..fbe6fa91 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,7 +19,11 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; -import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { + getDashboardAuthConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; const router = Router(); @@ -32,6 +36,73 @@ const MODEL_ENV_KEYS = [ ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; +function logRouteError(context: string, error: unknown): void { + if (error instanceof Error) { + console.error(`[settings-routes] ${context}: ${error.message}`); + return; + } + console.error(`[settings-routes] ${context}: unknown error`); +} + +function respondInternalError( + res: Response, + error: unknown, + fallbackMessage: string, + statusCode = 500 +): void { + logRouteError(fallbackMessage, error); + res.status(statusCode).json({ error: fallbackMessage }); +} + +function isLoopbackAddress(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().replace(/^\[|\]$/g, ''); + return ( + normalized === '::1' || + normalized === '127.0.0.1' || + normalized.startsWith('127.') || + normalized === '::ffff:127.0.0.1' || + normalized.startsWith('::ffff:127.') + ); +} + +function requireSensitiveLocalAccess(req: Request, res: Response): boolean { + const dashboardAuth = getDashboardAuthConfig(); + if (dashboardAuth.enabled) { + return true; + } + + const forwarded = req.headers['x-forwarded-for']; + const firstForwarded = + typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined; + const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip; + + if (isLoopbackAddress(candidateAddress)) { + return true; + } + + res.status(403).json({ + error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.', + }); + return false; +} + +function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + + if (message.includes('failed to acquire config lock')) { + return { statusCode: 409, message: 'Configuration is busy. Retry in a moment.' }; + } + if (message.includes('eacces') || message.includes('eperm') || message.includes('permission')) { + return { statusCode: 403, message: 'Insufficient permission to update configuration.' }; + } + if (message.includes('enospc') || message.includes('no space left')) { + return { statusCode: 507, message: 'Insufficient disk space to update configuration.' }; + } + + return { statusCode: 500, message: 'Failed to update Antigravity power user mode.' }; +} + /** * Helper: Resolve settings path for profile or variant * Variants have settings paths in config, regular profiles use {name}.settings.json @@ -145,7 +216,7 @@ router.get('/:profile', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -172,7 +243,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { path: settingsPath, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -265,7 +336,7 @@ router.put('/:profile', (req: Request, res: Response): void => { }), }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -287,7 +358,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -346,7 +417,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { res.status(201).json({ preset }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -378,7 +449,7 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => res.json({ success: true }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -387,14 +458,16 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => /** * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting */ -router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => { +router.get('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { const config = loadOrCreateUnifiedConfig(); res.json({ antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to load Antigravity power user mode.'); } }); @@ -402,8 +475,12 @@ router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => { * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting */ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { - const { antigravityAckBypass } = req.body as { antigravityAckBypass?: unknown }; + const body = req.body as { antigravityAckBypass?: unknown } | null | undefined; + const antigravityAckBypass = + body && typeof body === 'object' ? body.antigravityAckBypass : undefined; if (typeof antigravityAckBypass !== 'boolean') { res.status(400).json({ error: 'antigravityAckBypass must be a boolean' }); @@ -422,7 +499,8 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { antigravityAckBypass, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + const classified = classifyConfigSaveFailure(error); + respondInternalError(res, error, classified.message, classified.statusCode); } }); @@ -444,7 +522,7 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -452,7 +530,9 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => { * GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked * NOTE: Sensitive endpoint - no caching, localhost only */ -router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { +router.get('/auth/tokens/raw', (req: Request, res: Response): void => { + if (!requireSensitiveLocalAccess(req, res)) return; + try { // Prevent caching of sensitive data res.setHeader('Cache-Control', 'no-store'); @@ -470,7 +550,7 @@ router.get('/auth/tokens/raw', (_req: Request, res: Response): void => { }, }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Failed to load raw auth tokens.'); } }); @@ -506,7 +586,7 @@ router.put('/auth/tokens', (req: Request, res: Response): void => { message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -530,7 +610,7 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo message: 'Restart CLIProxy to apply changes', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } }); @@ -558,7 +638,7 @@ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => { message: 'Tokens reset to defaults. Restart CLIProxy to apply.', }); } catch (error) { - res.status(500).json({ error: (error as Error).message }); + respondInternalError(res, error, 'Internal server error.'); } });