From 3b1271b5e4bd48e65d599323c07eebdffd7c8995 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 17:23:02 +0700 Subject: [PATCH 01/94] fix(agy): enforce multi-step responsibility acknowledgement for antigravity oauth --- src/cliproxy/account-safety.ts | 9 + src/cliproxy/antigravity-responsibility.ts | 212 ++++++++++++++++++ src/cliproxy/auth/auth-types.ts | 2 + src/cliproxy/auth/oauth-handler.ts | 20 ++ src/cliproxy/executor/index.ts | 28 +++ src/commands/help-command.ts | 5 + src/web-server/routes/cliproxy-auth-routes.ts | 27 ++- .../antigravity-responsibility.test.ts | 78 +++++++ .../account/account-safety-warning-card.tsx | 48 ++-- .../components/account/add-account-dialog.tsx | 41 +++- .../antigravity-responsibility-checklist.tsx | 154 +++++++++++++ .../antigravity-responsibility-constants.ts | 25 +++ ui/src/hooks/use-cliproxy-auth-flow.ts | 8 + ui/src/pages/cliproxy.tsx | 5 +- 14 files changed, 646 insertions(+), 16 deletions(-) create mode 100644 src/cliproxy/antigravity-responsibility.ts create mode 100644 tests/unit/cliproxy/antigravity-responsibility.test.ts create mode 100644 ui/src/components/account/antigravity-responsibility-checklist.tsx create mode 100644 ui/src/components/account/antigravity-responsibility-constants.ts diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index 6c7e049d..8f981c33 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -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(''); } diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts new file mode 100644 index 00000000..0197e651 --- /dev/null +++ b/src/cliproxy/antigravity-responsibility.ts @@ -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 { + 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 { + 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 { + 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; + + 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 { + 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(); + } +} diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 92080547..9d1cf401 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -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) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f05316d2..f4a2d3b2 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 220ccdd2..c3f2f3bb 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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, ]; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index c7b52ae7..fd80db89 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 --accounts', 'List all accounts'], ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], + [ + 'ccs agy --accept-agr-risk', + 'Bypass interactive Antigravity confirmation (you accept full responsibility)', + ], [ 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b07da8e2..14026eca 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { 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, }); diff --git a/tests/unit/cliproxy/antigravity-responsibility.test.ts b/tests/unit/cliproxy/antigravity-responsibility.test.ts new file mode 100644 index 00000000..0ae0ca7c --- /dev/null +++ b/tests/unit/cliproxy/antigravity-responsibility.test.ts @@ -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(); + }); +}); diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index a2e00ae0..7d167cfd 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -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 ccs agy. + + ) : ( + <> + Using one Google account for both ccs gemini and{' '} + ccs agy 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 (
-

Account Safety Warning

-

- Issue #509 · Shared Gemini + AGY account risk -

+

{title}

+

{subtitle}

-

- Using one Google account for both ccs gemini and{' '} - ccs agy can trigger account disable/ban. -

-

- If you want to keep Google AI access, do not continue this shared-account setup. -

+

{firstLine}

+

{secondLine}

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({

- Read issue #509 + {issueLabel} diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 8a203a9b..41041441 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -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(null); const [acknowledgedRisk, setAcknowledgedRisk] = useState(false); + const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); const [kiroAuthMethod, setKiroAuthMethod] = useState(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({
+ {requiresAgyResponsibilityFlow && !showAuthUI && ( + { + setAgyRiskChecklist(value); + setLocalError(null); + }} + disabled={isPending} + /> + )} + {requiresSafetyAcknowledgement && !showAuthUI && ( { @@ -406,6 +444,7 @@ export function AddAccountDialog({ disabled={ isPending || (requiresNickname && !nicknameTrimmed) || + (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || (requiresSafetyAcknowledgement && !acknowledgedRisk) } > diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx new file mode 100644 index 00000000..6940c246 --- /dev/null +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -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) => { + onChange({ ...value, ...next }); + }; + + return ( +
+
+ +
+
+
+
+ +
+
+

Antigravity OAuth Responsibility

+

+ Complete all 4 steps before you can authenticate. +

+
+
+ + Mandatory + +
+ +
+
+ Completion + {completedSteps}/4 steps +
+ +
+ +
+
+ setValue({ reviewedIssue622: Boolean(checked) })} + disabled={disabled} + /> + +
+ +
+ setValue({ understandsBanRisk: Boolean(checked) })} + disabled={disabled} + /> + +
+ +
+ + setValue({ acceptsFullResponsibility: Boolean(checked) }) + } + disabled={disabled} + /> + +
+
+ +
+
+ + Step 4: Type exact phrase to continue +
+ setValue({ typedPhrase: e.target.value })} + placeholder={ANTIGRAVITY_ACK_PHRASE} + disabled={disabled} + className="font-mono text-xs" + /> +
+ + +
+
+ ); +} diff --git a/ui/src/components/account/antigravity-responsibility-constants.ts b/ui/src/components/account/antigravity-responsibility-constants.ts new file mode 100644 index 00000000..4ecda80b --- /dev/null +++ b/ui/src/components/account/antigravity-responsibility-constants.ts @@ -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 + ); +} diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index a1f8f954..4f8bb8f9 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -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({ diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5d12c178..d340a142 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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 */}
- {showAccountSafetyWarning && } + {showAccountSafetyWarning && ( + + )} {selectedVariantData && parentAuthForVariant ? ( // Variant selected - show ProviderEditor with variant profile name From 7b4bd804c88d1f9355600d8945ef683c45d854bc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 17:25:47 +0700 Subject: [PATCH 02/94] fix(agy): use type-only import for ui checklist value --- .../account/antigravity-responsibility-checklist.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ui/src/components/account/antigravity-responsibility-checklist.tsx b/ui/src/components/account/antigravity-responsibility-checklist.tsx index 6940c246..e1f7e4f1 100644 --- a/ui/src/components/account/antigravity-responsibility-checklist.tsx +++ b/ui/src/components/account/antigravity-responsibility-checklist.tsx @@ -5,10 +5,8 @@ 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'; +import { ANTIGRAVITY_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; +import type { AntigravityRiskChecklistValue } from '@/components/account/antigravity-responsibility-constants'; interface AntigravityResponsibilityChecklistProps { className?: string; From d3c271ab2c6bcf85e14faffb16e7f976c86c0312 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 17:42:57 +0700 Subject: [PATCH 03/94] feat(agy): add power-user bypass for responsibility acknowledgement --- src/cliproxy/antigravity-responsibility.ts | 16 +++- src/config/unified-config-loader.ts | 20 +++++ src/config/unified-config-types.ts | 19 +++++ src/web-server/routes/cliproxy-auth-routes.ts | 9 +- src/web-server/routes/settings-routes.ts | 43 ++++++++++ .../antigravity-responsibility.test.ts | 54 +++++++++++- .../components/account/add-account-dialog.tsx | 63 +++++++++++++- .../pages/settings/sections/auth-section.tsx | 83 ++++++++++++++++++- 8 files changed, 299 insertions(+), 8 deletions(-) diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts index 0197e651..d08d502b 100644 --- a/src/cliproxy/antigravity-responsibility.ts +++ b/src/cliproxy/antigravity-responsibility.ts @@ -10,6 +10,7 @@ import { createInterface, Interface } from 'readline'; import { fail, info, ok, warn } from '../utils/ui'; +import { getCliproxySafetyConfig } from '../config/unified-config-loader'; export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/622'; export const ANTIGRAVITY_PROJECT_ID_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/619'; @@ -47,6 +48,19 @@ function isTruthyEnv(value: string | undefined): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } +export function isAntigravityResponsibilityBypassEnabled(): boolean { + if (isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + return true; + } + + try { + const safety = getCliproxySafetyConfig(); + return safety.antigravity_ack_bypass === true; + } catch { + return false; + } +} + function askQuestion(rl: Interface, prompt: string): Promise { return new Promise((resolve) => { let settled = false; @@ -160,7 +174,7 @@ export function validateAntigravityRiskAcknowledgement(payload: unknown): Valida export async function ensureCliAntigravityResponsibility( options: EnsureCliRiskOptions ): Promise { - if (options.acceptedByFlag || isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) { + if (options.acceptedByFlag || isAntigravityResponsibilityBypassEnabled()) { return true; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8f82351c..29f6ccef 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -18,10 +18,12 @@ import { DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, + DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, + CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, @@ -249,6 +251,11 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { request_log: partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false, }, + safety: { + antigravity_ack_bypass: + partial.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }, // Auth config - preserve user values, no defaults (uses constants as fallback) auth: partial.cliproxy?.auth, // Backend selection - validate and preserve user choice (original vs plus) @@ -892,6 +899,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get cliproxy safety configuration. + * Returns defaults if not configured. + */ +export function getCliproxySafetyConfig(): CLIProxySafetyConfig { + const config = loadOrCreateUnifiedConfig(); + return { + antigravity_ack_bypass: + config.cliproxy?.safety?.antigravity_ack_bypass ?? + DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass, + }; +} + /** * Get thinking configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b1d1d9c..40b308c9 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -156,6 +156,22 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * CLIProxy safety configuration. + * Controls high-risk flow safeguards for supported providers. + */ +export interface CLIProxySafetyConfig { + /** Allow skipping AGY responsibility acknowledgement flow (default: false) */ + antigravity_ack_bypass?: boolean; +} + +/** + * Default CLIProxy safety configuration. + */ +export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = { + antigravity_ack_bypass: false, +}; + /** * Token refresh configuration. * Manages background token refresh worker settings. @@ -187,6 +203,8 @@ export interface CLIProxyConfig { variants: Record; /** Logging configuration (disabled by default) */ logging?: CLIProxyLoggingConfig; + /** Safety controls for high-risk provider flows */ + safety?: CLIProxySafetyConfig; /** Kiro: disable incognito browser mode (use normal browser to save credentials) */ kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ @@ -777,6 +795,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, request_log: false, }, + safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG }, auto_sync: true, }, preferences: { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 14026eca..6033f58f 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -46,7 +46,10 @@ 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'; +import { + validateAntigravityRiskAcknowledgement, + isAntigravityResponsibilityBypassEnabled, +} from '../../cliproxy/antigravity-responsibility'; const router = Router(); @@ -406,7 +409,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise // ==================== Auth Tokens ==================== +/** + * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting + */ +router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => { + 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 }); + } +}); + +/** + * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting + */ +router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { + try { + const { antigravityAckBypass } = req.body as { antigravityAckBypass?: unknown }; + + if (typeof antigravityAckBypass !== 'boolean') { + res.status(400).json({ error: 'antigravityAckBypass must be a boolean' }); + return; + } + + const config = loadOrCreateUnifiedConfig(); + config.cliproxy.safety = { + ...(config.cliproxy.safety ?? {}), + antigravity_ack_bypass: antigravityAckBypass, + }; + saveUnifiedConfig(config); + + res.json({ + success: true, + antigravityAckBypass, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/settings/auth/tokens - Get current auth token status (masked) */ diff --git a/tests/unit/cliproxy/antigravity-responsibility.test.ts b/tests/unit/cliproxy/antigravity-responsibility.test.ts index 0ae0ca7c..c84b4ff6 100644 --- a/tests/unit/cliproxy/antigravity-responsibility.test.ts +++ b/tests/unit/cliproxy/antigravity-responsibility.test.ts @@ -1,12 +1,44 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { ANTIGRAVITY_ACK_PHRASE, ANTIGRAVITY_ACK_VERSION, hasAntigravityRiskAcceptanceFlag, + isAntigravityResponsibilityBypassEnabled, validateAntigravityRiskAcknowledgement, } from '../../../src/cliproxy/antigravity-responsibility'; describe('antigravity-responsibility', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalAgyRiskEnv: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-risk-test-')); + originalCcsHome = process.env.CCS_HOME; + originalAgyRiskEnv = process.env.CCS_ACCEPT_AGY_RISK; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_ACCEPT_AGY_RISK; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalAgyRiskEnv !== undefined) { + process.env.CCS_ACCEPT_AGY_RISK = originalAgyRiskEnv; + } else { + delete process.env.CCS_ACCEPT_AGY_RISK; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + it('accepts a complete acknowledgement payload', () => { const result = validateAntigravityRiskAcknowledgement({ version: ANTIGRAVITY_ACK_VERSION, @@ -75,4 +107,24 @@ describe('antigravity-responsibility', () => { expect(hasAntigravityRiskAcceptanceFlag(['--accept-antigravity-risk'])).toBeTrue(); expect(hasAntigravityRiskAcceptanceFlag(['--auth'])).toBeFalse(); }); + + it('enables bypass when CCS_ACCEPT_AGY_RISK is set', () => { + process.env.CCS_ACCEPT_AGY_RISK = 'true'; + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); + + it('enables bypass when cliproxy safety setting is enabled', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 8 +cliproxy: + safety: + antigravity_ack_bypass: true +` + ); + + expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue(); + }); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 41041441..e426c0df 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -25,7 +25,7 @@ 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'; @@ -68,6 +68,8 @@ export function AddAccountDialog({ const [localError, setLocalError] = useState(null); const [acknowledgedRisk, setAcknowledgedRisk] = useState(false); const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); + const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); @@ -75,7 +77,8 @@ export function AddAccountDialog({ const isKiro = provider === 'kiro'; const requiresSafetyAcknowledgement = provider === 'gemini'; - const requiresAgyResponsibilityFlow = provider === 'agy'; + const requiresAgyResponsibilityFlow = provider === 'agy' && !agyAckBypassEnabled; + const isAgyBypassStatePending = provider === 'agy' && agyAckBypassLoading; const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const defaultDeviceCode = isDeviceCodeProvider(provider); const requiresNickname = isNicknameRequiredProvider(provider); @@ -92,6 +95,8 @@ export function AddAccountDialog({ setLocalError(null); setAcknowledgedRisk(false); setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); @@ -105,6 +110,44 @@ export function AddAccountDialog({ } }, [provider, open]); + useEffect(() => { + let cancelled = false; + + if (!open || provider !== 'agy') { + setAgyAckBypassEnabled(false); + setAgyAckBypassLoading(false); + return; + } + + const loadAgyBypassState = async () => { + try { + setAgyAckBypassLoading(true); + 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 }; + if (!cancelled) { + setAgyAckBypassEnabled(data.antigravityAckBypass === true); + } + } catch { + if (!cancelled) { + setAgyAckBypassEnabled(false); + } + } finally { + if (!cancelled) { + setAgyAckBypassLoading(false); + } + } + }; + + loadAgyBypassState(); + + return () => { + cancelled = true; + }; + }, [open, provider]); + // When authFlow completes successfully (polling detected success), apply preset and close useEffect(() => { if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) { @@ -153,6 +196,10 @@ export function AddAccountDialog({ * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { + if (isAgyBypassStatePending) { + setLocalError('Loading Antigravity safety settings. Please wait a moment and retry.'); + return; + } if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) { setLocalError( 'Complete all Antigravity responsibility steps before authenticating this provider.' @@ -241,6 +288,17 @@ export function AddAccountDialog({ /> )} + {provider === 'agy' && agyAckBypassEnabled && !showAuthUI && ( +
+
+ + Power user mode enabled +
+ AGY responsibility checklist is skipped from Settings {'>'} Auth. You accept full + responsibility for OAuth/account risk. +
+ )} + {requiresSafetyAcknowledgement && !showAuthUI && ( (null); const [copiedApiKey, setCopiedApiKey] = useState(false); const [copiedSecret, setCopiedSecret] = useState(false); + const [agyAckBypass, setAgyAckBypass] = useState(false); + const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); + const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); // Fetch tokens const fetchTokens = useCallback(async () => { @@ -71,11 +76,28 @@ export default function AuthSection() { } }, []); + 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 Antigravity power user settings'); + } + const data = (await response.json()) as { antigravityAckBypass?: boolean }; + setAgyAckBypass(data.antigravityAckBypass === true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setAgyAckBypassLoading(false); + } + }, []); + // Load on mount useEffect(() => { fetchTokens(); + fetchAgyAckBypass(); fetchRawConfig(); - }, [fetchTokens, fetchRawConfig]); + }, [fetchTokens, fetchAgyAckBypass, fetchRawConfig]); // Clear success after timeout useEffect(() => { @@ -197,6 +219,41 @@ export default function AuthSection() { setTimeout(() => setCopiedSecret(false), 2000); }; + const saveAgyAckBypass = async (nextValue: boolean) => { + if (nextValue) { + const confirmed = window.confirm( + 'Enable Antigravity power user mode?\n\nThis disables AGY responsibility checklist prompts in CLI and dashboard. You accept full responsibility for OAuth/account risk.' + ); + if (!confirmed) return; + } + + try { + setAgyAckBypassSaving(true); + setError(null); + + const response = await fetch('/api/settings/auth/antigravity-risk', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ antigravityAckBypass: nextValue }), + }); + + if (!response.ok) { + const data = (await response.json()) as { error?: string }; + throw new Error(data.error || 'Failed to update Antigravity power user mode'); + } + + setAgyAckBypass(nextValue); + setSuccess( + nextValue ? 'Antigravity power user mode enabled.' : 'Antigravity power user mode disabled.' + ); + await fetchRawConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setAgyAckBypassSaving(false); + } + }; + if (loading || !tokens) { return (
@@ -360,6 +417,29 @@ export default function AuthSection() {
{/* Actions */} +
+
+
+
+ +

Antigravity Power User Mode

+
+

+ Skip AGY responsibility checklists in Add Account and `ccs agy` flows. +

+
+ +
+

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS + cannot assume responsibility for account loss. +

+
+
@@ -128,21 +128,12 @@ export function AntigravityResponsibilityChecklist({ diff --git a/ui/src/components/account/antigravity-responsibility-constants.ts b/ui/src/components/account/antigravity-responsibility-constants.ts index 4ecda80b..dad8544c 100644 --- a/ui/src/components/account/antigravity-responsibility-constants.ts +++ b/ui/src/components/account/antigravity-responsibility-constants.ts @@ -1,15 +1,15 @@ -export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v1'; -export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT FULL RESPONSIBILITY'; +export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; +export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT AGY RISK'; export interface AntigravityRiskChecklistValue { - reviewedIssue622: boolean; + reviewedIssue509: boolean; understandsBanRisk: boolean; acceptsFullResponsibility: boolean; typedPhrase: string; } export const DEFAULT_ANTIGRAVITY_RISK_CHECKLIST: AntigravityRiskChecklistValue = { - reviewedIssue622: false, + reviewedIssue509: false, understandsBanRisk: false, acceptsFullResponsibility: false, typedPhrase: '', @@ -17,7 +17,7 @@ export const DEFAULT_ANTIGRAVITY_RISK_CHECKLIST: AntigravityRiskChecklistValue = export function isAntigravityRiskChecklistComplete(value: AntigravityRiskChecklistValue): boolean { return ( - value.reviewedIssue622 && + value.reviewedIssue509 && value.understandsBanRisk && value.acceptsFullResponsibility && value.typedPhrase.trim().replace(/\s+/g, ' ').toUpperCase() === ANTIGRAVITY_ACK_PHRASE From b602ab99ab6a1da755d1c8fedfdae4e15a926a8a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 18:09:36 +0700 Subject: [PATCH 05/94] fix(auth): harden agy safety endpoints and config writes - add safer error responses for settings/auth routes to avoid leaking internals - guard sensitive AGY settings endpoints to localhost when dashboard auth is off - validate start-route bodies and reject OAuth start in remote mode for consistency - preserve cliproxy.kiro_no_incognito and token_refresh during config merges - enforce AGY acknowledgement in remote auth-token run/auth command paths --- src/cliproxy/executor/index.ts | 18 ++- src/config/unified-config-loader.ts | 5 + src/web-server/routes/cliproxy-auth-routes.ts | 72 ++++++----- src/web-server/routes/settings-routes.ts | 114 +++++++++++++++--- 4 files changed, 162 insertions(+), 47 deletions(-) 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.'); } }); From 6e20cdcdff18abc176d09acfe7b4a98e3475f54e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 18:09:56 +0700 Subject: [PATCH 06/94] fix(ui): improve agy auth UX and flow resilience - re-sync AGY bypass state when backend requires risk acknowledgement - harden auth flow response parsing for non-JSON error payloads - prevent save collisions in Auth settings and add switch accessibility labels - disable risky concurrent actions and add guarded refresh behavior --- .../components/account/add-account-dialog.tsx | 64 ++++++++++++++++--- ui/src/hooks/use-cliproxy-auth-flow.ts | 55 +++++++++++----- .../pages/settings/sections/auth-section.tsx | 56 +++++++++++----- 3 files changed, 135 insertions(+), 40 deletions(-) diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index e426c0df..6dd6dba7 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -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, @@ -88,6 +88,15 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const errorMessage = localError || authFlow.error; + const fetchAgyBypassState = useCallback(async (): Promise => { + 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(''); @@ -122,13 +131,9 @@ export function AddAccountDialog({ const loadAgyBypassState = async () => { try { setAgyAckBypassLoading(true); - 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 }; + const enabled = await fetchAgyBypassState(); if (!cancelled) { - setAgyAckBypassEnabled(data.antigravityAckBypass === true); + setAgyAckBypassEnabled(enabled); } } catch { if (!cancelled) { @@ -146,7 +151,48 @@ export function AddAccountDialog({ return () => { cancelled = true; }; - }, [open, provider]); + }, [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(() => { @@ -226,7 +272,7 @@ export function AddAccountDialog({ riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, - reviewedIssue622: agyRiskChecklist.reviewedIssue622, + reviewedIssue509: agyRiskChecklist.reviewedIssue509, understandsBanRisk: agyRiskChecklist.understandsBanRisk, acceptsFullResponsibility: agyRiskChecklist.acceptsFullResponsibility, typedPhrase: agyRiskChecklist.typedPhrase, diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 4f8bb8f9..c31c42e8 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -30,7 +30,7 @@ interface StartAuthOptions { startEndpoint?: 'start' | 'start-url'; riskAcknowledgement?: { version: string; - reviewedIssue622: boolean; + reviewedIssue509: boolean; understandsBanRisk: boolean; acceptsFullResponsibility: boolean; typedPhrase: string; @@ -42,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> { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text) as Record; + } 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, @@ -206,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 @@ -215,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, @@ -247,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); } } @@ -319,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'; diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index 6061629d..fec3c31b 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -3,7 +3,7 @@ * Settings section for CLIProxy auth tokens (API key and management secret) */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -56,6 +56,7 @@ export default function AuthSection() { const [agyAckBypass, setAgyAckBypass] = useState(false); const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); + const agyAckBypassSavingRef = useRef(false); // Fetch tokens const fetchTokens = useCallback(async () => { @@ -117,6 +118,8 @@ export default function AuthSection() { // Save all changes const saveChanges = async () => { + if (agyAckBypassSaving) return; + const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value; const hasSecretChange = editedSecret !== null && editedSecret !== tokens?.managementSecret.value; @@ -156,6 +159,8 @@ export default function AuthSection() { // Regenerate management secret const regenerateSecret = async () => { + if (agyAckBypassSaving) return; + try { setSaving(true); setError(null); @@ -180,6 +185,8 @@ export default function AuthSection() { // Reset to defaults const resetToDefaults = async () => { + if (agyAckBypassSaving) return; + try { setSaving(true); setError(null); @@ -220,14 +227,17 @@ export default function AuthSection() { }; const saveAgyAckBypass = async (nextValue: boolean) => { + if (agyAckBypassSavingRef.current || agyAckBypassSaving || saving) return; + if (nextValue) { const confirmed = window.confirm( - 'Enable Antigravity power user mode?\n\nThis disables AGY responsibility checklist prompts in CLI and dashboard. You accept full responsibility for OAuth/account risk.' + 'Enable AGY power user mode?\n\nThis skips AGY safety prompts. You accept the OAuth risk.' ); if (!confirmed) return; } try { + agyAckBypassSavingRef.current = true; setAgyAckBypassSaving(true); setError(null); @@ -250,10 +260,18 @@ export default function AuthSection() { } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { + agyAckBypassSavingRef.current = false; setAgyAckBypassSaving(false); } }; + const refreshAll = async () => { + if (loading || saving || agyAckBypassSaving) return; + setError(null); + setSuccess(null); + await Promise.all([fetchTokens(), fetchAgyAckBypass(), fetchRawConfig()]); + }; + if (loading || !tokens) { return (
@@ -326,7 +344,7 @@ export default function AuthSection() { value={displayApiKey} onChange={(e) => setEditedApiKey(e.target.value)} placeholder="API key" - disabled={saving} + disabled={saving || agyAckBypassSaving} className="pr-20 font-mono text-sm" />
@@ -377,7 +395,7 @@ export default function AuthSection() { value={displaySecret} onChange={(e) => setEditedSecret(e.target.value)} placeholder="Management secret" - disabled={saving} + disabled={saving || agyAckBypassSaving} className="pr-20 font-mono text-sm" />
@@ -408,7 +426,7 @@ export default function AuthSection() { variant="outline" size="sm" onClick={regenerateSecret} - disabled={saving} + disabled={saving || agyAckBypassSaving} title="Generate new secure secret" > @@ -429,15 +447,23 @@ export default function AuthSection() {

-

- Use only if you fully understand the OAuth suspension/ban risk pattern (#622). CCS +

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS cannot assume responsibility for account loss.

+ + Toggle AGY power user mode +
@@ -445,7 +471,11 @@ export default function AuthSection() { variant="outline" size="sm" onClick={resetToDefaults} - disabled={saving || (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom)} + disabled={ + saving || + agyAckBypassSaving || + (!tokens.apiKey.isCustom && !tokens.managementSecret.isCustom) + } className="gap-2" > @@ -463,12 +493,8 @@ export default function AuthSection() {
- AGY responsibility checklist is skipped from Settings {'>'} Auth. You accept full + AGY responsibility checklist is skipped from Settings {'>'} Proxy. You accept full responsibility for OAuth/account risk.
)} diff --git a/ui/src/pages/settings/sections/auth-section.tsx b/ui/src/pages/settings/sections/auth-section.tsx index fec3c31b..bfbbb59a 100644 --- a/ui/src/pages/settings/sections/auth-section.tsx +++ b/ui/src/pages/settings/sections/auth-section.tsx @@ -3,12 +3,11 @@ * Settings section for CLIProxy auth tokens (API key and management secret) */ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; import { RefreshCw, CheckCircle2, @@ -21,7 +20,6 @@ import { Check, KeyRound, ShieldCheck, - ShieldAlert, Save, } from 'lucide-react'; import { useRawConfig } from '../hooks'; @@ -53,10 +51,6 @@ export default function AuthSection() { const [editedSecret, setEditedSecret] = useState(null); const [copiedApiKey, setCopiedApiKey] = useState(false); const [copiedSecret, setCopiedSecret] = useState(false); - const [agyAckBypass, setAgyAckBypass] = useState(false); - const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(true); - const [agyAckBypassSaving, setAgyAckBypassSaving] = useState(false); - const agyAckBypassSavingRef = useRef(false); // Fetch tokens const fetchTokens = useCallback(async () => { @@ -77,28 +71,11 @@ export default function AuthSection() { } }, []); - 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 Antigravity power user settings'); - } - const data = (await response.json()) as { antigravityAckBypass?: boolean }; - setAgyAckBypass(data.antigravityAckBypass === true); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setAgyAckBypassLoading(false); - } - }, []); - // Load on mount useEffect(() => { fetchTokens(); - fetchAgyAckBypass(); fetchRawConfig(); - }, [fetchTokens, fetchAgyAckBypass, fetchRawConfig]); + }, [fetchTokens, fetchRawConfig]); // Clear success after timeout useEffect(() => { @@ -118,8 +95,6 @@ export default function AuthSection() { // Save all changes const saveChanges = async () => { - if (agyAckBypassSaving) return; - const hasApiKeyChange = editedApiKey !== null && editedApiKey !== tokens?.apiKey.value; const hasSecretChange = editedSecret !== null && editedSecret !== tokens?.managementSecret.value; @@ -159,8 +134,6 @@ export default function AuthSection() { // Regenerate management secret const regenerateSecret = async () => { - if (agyAckBypassSaving) return; - try { setSaving(true); setError(null); @@ -185,8 +158,6 @@ export default function AuthSection() { // Reset to defaults const resetToDefaults = async () => { - if (agyAckBypassSaving) return; - try { setSaving(true); setError(null); @@ -226,50 +197,11 @@ export default function AuthSection() { setTimeout(() => setCopiedSecret(false), 2000); }; - const saveAgyAckBypass = async (nextValue: boolean) => { - 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 { - agyAckBypassSavingRef.current = true; - setAgyAckBypassSaving(true); - setError(null); - - const response = await fetch('/api/settings/auth/antigravity-risk', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ antigravityAckBypass: nextValue }), - }); - - if (!response.ok) { - const data = (await response.json()) as { error?: string }; - throw new Error(data.error || 'Failed to update Antigravity power user mode'); - } - - setAgyAckBypass(nextValue); - setSuccess( - nextValue ? 'Antigravity power user mode enabled.' : 'Antigravity power user mode disabled.' - ); - await fetchRawConfig(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - agyAckBypassSavingRef.current = false; - setAgyAckBypassSaving(false); - } - }; - const refreshAll = async () => { - if (loading || saving || agyAckBypassSaving) return; + if (loading || saving) return; setError(null); setSuccess(null); - await Promise.all([fetchTokens(), fetchAgyAckBypass(), fetchRawConfig()]); + await Promise.all([fetchTokens(), fetchRawConfig()]); }; if (loading || !tokens) { @@ -344,7 +276,7 @@ export default function AuthSection() { value={displayApiKey} onChange={(e) => setEditedApiKey(e.target.value)} placeholder="API key" - disabled={saving || agyAckBypassSaving} + disabled={saving} className="pr-20 font-mono text-sm" />
@@ -395,7 +327,7 @@ export default function AuthSection() { value={displaySecret} onChange={(e) => setEditedSecret(e.target.value)} placeholder="Management secret" - disabled={saving || agyAckBypassSaving} + disabled={saving} className="pr-20 font-mono text-sm" />
@@ -426,7 +358,7 @@ export default function AuthSection() { variant="outline" size="sm" onClick={regenerateSecret} - disabled={saving || agyAckBypassSaving} + disabled={saving} title="Generate new secure secret" > @@ -434,48 +366,12 @@ export default function AuthSection() {
- {/* Actions */} -
-
-
-
- -

Antigravity Power User Mode

-
-

- Skip AGY responsibility checklists in Add Account and `ccs agy` flows. -

-
- -
-

- Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS - cannot assume responsibility for account loss. -

- - Toggle AGY power user mode - -
-
+ {/* Safety */} +
+

+ + Safety +

+
+
+
+

Antigravity Power User Mode

+

+ Skip AGY responsibility checklist in Add Account and `ccs agy` flows. +

+
+ +
+

+ Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS + cannot assume responsibility for account loss. +

+ + Toggle AGY power user mode + +
+
+ {/* Remote Settings - Show when remote mode is enabled */} {isRemoteMode && ( { fetchConfig(); + fetchAgyAckBypass(); fetchRawConfig(); fetchBackend(); checkPlusOnlyVariants(); }} - disabled={loading || saving} + disabled={loading || saving || agyAckBypassSaving} className="w-full" > From e22d331bf19131c47ec59522c8fc573e257d4381 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 18:24:40 +0700 Subject: [PATCH 08/94] fix(ui): unify gemini and agy safety warning banner - collapse provider-specific warning variants into one shared card - keep issue #509 as the single risk reference for both flows - reuse cliproxy warning card space for Settings > Proxy pointer --- .../account/account-safety-warning-card.tsx | 42 +++++++++---------- .../components/account/add-account-dialog.tsx | 1 - ui/src/pages/cliproxy.tsx | 3 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index b453f6de..8272caab 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -1,4 +1,4 @@ -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 { Label } from '@/components/ui/label'; @@ -6,45 +6,36 @@ import { cn } from '@/lib/utils'; interface AccountSafetyWarningCardProps { className?: string; - provider?: 'gemini' | 'agy'; showAcknowledgement?: boolean; acknowledged?: boolean; onAcknowledgedChange?: (value: boolean) => void; disabled?: boolean; + showProxySettingsLink?: boolean; } export function AccountSafetyWarningCard({ className, - provider = 'gemini', showAcknowledgement = false, acknowledged = false, onAcknowledgedChange, disabled = false, + showProxySettingsLink = false, }: AccountSafetyWarningCardProps) { - const isAgy = provider === 'agy'; - - const title = isAgy ? 'Antigravity OAuth Risk' : 'Account Safety Warning'; - const subtitle = isAgy - ? 'Issue #509 · Third-party OAuth ban risk' - : 'Issue #509 · Shared Gemini + AGY account risk'; - const firstLine = isAgy ? ( + const title = 'OAuth Account Safety Warning'; + const subtitle = 'Issue #509 · Gemini + AGY OAuth risk'; + const firstLine = ( <> - Antigravity OAuth currently has active ban/suspension patterns. Complete the responsibility - steps before running auth or starting ccs agy. - - ) : ( - <> - Using one Google account for both ccs gemini and{' '} - ccs agy can trigger account disable/ban. + Issue #509 documents suspension/ban reports tied to ccs agy{' '} + and shared-account usage between ccs gemini and{' '} + ccs agy. ); - 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 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 (
+ {showProxySettingsLink && ( + + + {proxySettingsLabel} + + )} Applies to CLI and dashboard auth diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 3d064d04..a387eb92 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -347,7 +347,6 @@ export function AddAccountDialog({ {requiresSafetyAcknowledgement && !showAuthUI && ( { diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index d340a142..35c7704c 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -248,7 +248,6 @@ export function CliproxyPage() { .toLowerCase() .trim(); const showAccountSafetyWarning = warningProvider === 'gemini' || warningProvider === 'agy'; - const warningProviderType = warningProvider === 'agy' ? 'agy' : 'gemini'; const handleRefresh = () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); @@ -396,7 +395,7 @@ export function CliproxyPage() { {/* Right Panel */}
{showAccountSafetyWarning && ( - + )} {selectedVariantData && parentAuthForVariant ? ( From c0eb786127d7552254efc170be040d162c8d8b09 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 18:32:07 +0700 Subject: [PATCH 09/94] fix(safety): unify add-account typed risk phrase - change required acknowledgement phrase to "I ACCEPT RISK" - apply typed phrase requirement to Gemini and Antigravity add-account flows - keep CLI/server and UI phrase validation aligned - update antigravity responsibility unit test --- src/cliproxy/antigravity-responsibility.ts | 3 +- .../antigravity-responsibility.test.ts | 2 +- .../account/account-safety-warning-card.tsx | 41 +++++++++++-------- .../components/account/add-account-dialog.tsx | 25 +++++++---- .../antigravity-responsibility-constants.ts | 3 +- 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/cliproxy/antigravity-responsibility.ts b/src/cliproxy/antigravity-responsibility.ts index 1f89b5a2..c16722cd 100644 --- a/src/cliproxy/antigravity-responsibility.ts +++ b/src/cliproxy/antigravity-responsibility.ts @@ -14,7 +14,8 @@ import { getCliproxySafetyConfig } from '../config/unified-config-loader'; export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509'; export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; -export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT AGY RISK'; +export const RISK_ACK_PHRASE = 'I ACCEPT RISK'; +export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE; export const ANTIGRAVITY_ACCEPT_RISK_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk']; type AgyRiskContext = 'oauth' | 'run'; diff --git a/tests/unit/cliproxy/antigravity-responsibility.test.ts b/tests/unit/cliproxy/antigravity-responsibility.test.ts index 420c142a..0777a60d 100644 --- a/tests/unit/cliproxy/antigravity-responsibility.test.ts +++ b/tests/unit/cliproxy/antigravity-responsibility.test.ts @@ -57,7 +57,7 @@ describe('antigravity-responsibility', () => { reviewedIssue509: true, understandsBanRisk: true, acceptsFullResponsibility: true, - typedPhrase: ' i accept agy risk ', + typedPhrase: ' i accept risk ', }); expect(result.valid).toBeTrue(); diff --git a/ui/src/components/account/account-safety-warning-card.tsx b/ui/src/components/account/account-safety-warning-card.tsx index 8272caab..ad646ad1 100644 --- a/ui/src/components/account/account-safety-warning-card.tsx +++ b/ui/src/components/account/account-safety-warning-card.tsx @@ -1,14 +1,16 @@ 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; } @@ -16,8 +18,9 @@ interface AccountSafetyWarningCardProps { export function AccountSafetyWarningCard({ className, showAcknowledgement = false, - acknowledged = false, - onAcknowledgedChange, + acknowledgementPhrase = RISK_ACK_PHRASE, + acknowledgementText = '', + onAcknowledgementTextChange, disabled = false, showProxySettingsLink = false, }: AccountSafetyWarningCardProps) { @@ -99,20 +102,22 @@ export function AccountSafetyWarningCard({
- {showAcknowledgement && onAcknowledgedChange && ( + {showAcknowledgement && onAcknowledgementTextChange && (
-
- onAcknowledgedChange(Boolean(checked))} - disabled={disabled} - /> - -
+ + onAcknowledgementTextChange(e.target.value)} + placeholder={acknowledgementPhrase} + disabled={disabled} + className="mt-2 font-mono text-xs" + />
)}
diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index a387eb92..094ce6ff 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -34,6 +34,7 @@ import { AntigravityResponsibilityChecklist } from '@/components/account/antigra import { ANTIGRAVITY_ACK_VERSION, DEFAULT_ANTIGRAVITY_RISK_CHECKLIST, + RISK_ACK_PHRASE, isAntigravityRiskChecklistComplete, } from '@/components/account/antigravity-responsibility-constants'; import { @@ -55,6 +56,10 @@ interface AddAccountDialogProps { isFirstAccount?: boolean; } +function normalizeRiskPhrase(value: string): string { + return value.trim().replace(/\s+/g, ' ').toUpperCase(); +} + export function AddAccountDialog({ open, onClose, @@ -66,7 +71,7 @@ export function AddAccountDialog({ const [callbackUrl, setCallbackUrl] = useState(''); const [copied, setCopied] = useState(false); const [localError, setLocalError] = useState(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); @@ -80,6 +85,7 @@ export function AddAccountDialog({ 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); @@ -102,7 +108,7 @@ export function AddAccountDialog({ setCallbackUrl(''); setCopied(false); setLocalError(null); - setAcknowledgedRisk(false); + setRiskAcknowledgementText(''); setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); setAgyAckBypassEnabled(false); setAgyAckBypassLoading(false); @@ -113,7 +119,7 @@ export function AddAccountDialog({ useEffect(() => { if (open) { - setAcknowledgedRisk(false); + setRiskAcknowledgementText(''); setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); setLocalError(null); } @@ -252,9 +258,9 @@ export function AddAccountDialog({ ); return; } - if (requiresSafetyAcknowledgement && !acknowledgedRisk) { + if (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) { setLocalError( - 'Please acknowledge the account safety warning before authenticating this provider.' + `Type "${RISK_ACK_PHRASE}" to acknowledge the account safety warning before authenticating this provider.` ); return; } @@ -348,9 +354,10 @@ export function AddAccountDialog({ {requiresSafetyAcknowledgement && !showAuthUI && ( { - setAcknowledgedRisk(value); + acknowledgementPhrase={RISK_ACK_PHRASE} + acknowledgementText={riskAcknowledgementText} + onAcknowledgementTextChange={(value) => { + setRiskAcknowledgementText(value); setLocalError(null); }} disabled={isPending} @@ -549,7 +556,7 @@ export function AddAccountDialog({ isAgyBypassStatePending || (requiresNickname && !nicknameTrimmed) || (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || - (requiresSafetyAcknowledgement && !acknowledgedRisk) + (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) } > diff --git a/ui/src/components/account/antigravity-responsibility-constants.ts b/ui/src/components/account/antigravity-responsibility-constants.ts index dad8544c..621d408d 100644 --- a/ui/src/components/account/antigravity-responsibility-constants.ts +++ b/ui/src/components/account/antigravity-responsibility-constants.ts @@ -1,5 +1,6 @@ export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2'; -export const ANTIGRAVITY_ACK_PHRASE = 'I ACCEPT AGY RISK'; +export const RISK_ACK_PHRASE = 'I ACCEPT RISK'; +export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE; export interface AntigravityRiskChecklistValue { reviewedIssue509: boolean; From 36d5cb723e39ec44926bec9a713ba7dbc121332d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Feb 2026 18:45:41 +0700 Subject: [PATCH 10/94] 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 --- src/config/unified-config-loader.ts | 170 ++++++++++++------ src/web-server/routes/settings-routes.ts | 16 +- .../pages/settings/sections/proxy/index.tsx | 149 +++++++++++++-- 3 files changed, 257 insertions(+), 78 deletions(-) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 83cda87e..1fbec374 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -719,14 +719,24 @@ function generateYamlWithComments(config: UnifiedConfig): string { } /** - * Save unified config to YAML file. - * Uses atomic write (temp file + rename) to prevent corruption. - * Uses lockfile to prevent concurrent writes. + * Sync sleep helper for lock retry loops. + * Uses Atomics.wait when available to avoid CPU-intensive busy-wait. */ -export function saveUnifiedConfig(config: UnifiedConfig): void { - const yamlPath = getConfigYamlPath(); - const dir = path.dirname(yamlPath); +function sleepSync(ms: number): void { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + } catch { + const end = Date.now() + ms; + while (Date.now() < end) { + /* busy-wait */ + } + } +} +/** + * Execute a callback while holding the config lock. + */ +function withConfigWriteLock(callback: () => T): T { // Acquire lock (retry for up to 1 second) const maxRetries = 10; const retryDelayMs = 100; @@ -736,18 +746,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { lockAcquired = true; break; } - // Synchronous sleep without CPU-intensive busy-wait - // Uses Atomics.wait which properly sleeps the thread - // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); - } catch { - // Fallback for environments without SharedArrayBuffer/Atomics support - const end = Date.now() + retryDelayMs; - while (Date.now() < end) { - /* busy-wait */ - } - } + sleepSync(retryDelayMs); } if (!lockAcquired) { @@ -755,57 +754,112 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { } try { - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; - - try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (error) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - } - // Classify filesystem errors - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOSPC') { - throw new Error('Disk full - cannot save config. Free up space and try again.'); - } else if (err.code === 'EROFS' || err.code === 'EACCES') { - throw new Error(`Cannot write config - check file permissions: ${err.message}`); - } - throw error; - } + return callback(); } finally { // Always release lock releaseLock(); } } +/** + * Load unified config directly from disk while lock is already held. + * Falls back to empty config when file doesn't exist. + */ +function loadUnifiedConfigWithLockHeld(): UnifiedConfig { + const yamlPath = getConfigYamlPath(); + if (!fs.existsSync(yamlPath)) { + return createEmptyUnifiedConfig(); + } + + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + throw new Error(`Invalid config format in ${yamlPath}`); + } + + const merged = mergeWithDefaults(parsed); + validateCompositeVariants(merged); + return merged; +} + +/** + * Write unified config to disk while lock is already held. + */ +function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } +} + +/** + * Save unified config to YAML file. + * Uses atomic write (temp file + rename) to prevent corruption. + * Uses lockfile to prevent concurrent writes. + */ +export function saveUnifiedConfig(config: UnifiedConfig): void { + withConfigWriteLock(() => { + writeUnifiedConfigWithLockHeld(config); + }); +} + +/** + * Atomically mutate unified config with lock held across read-modify-write. + * Prevents stale writes from overwriting concurrent updates. + */ +export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { + return withConfigWriteLock(() => { + const current = loadUnifiedConfigWithLockHeld(); + mutator(current); + writeUnifiedConfigWithLockHeld(current); + return current; + }); +} + /** * Update unified config with partial data. * Loads existing config, merges changes, and saves. */ export function updateUnifiedConfig(updates: Partial): UnifiedConfig { - const config = loadOrCreateUnifiedConfig(); - const updated = { ...config, ...updates }; - saveUnifiedConfig(updated); - return updated; + return mutateUnifiedConfig((config) => { + Object.assign(config, updates); + }); } /** diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index fbe6fa91..54a3e265 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -22,7 +22,7 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { getDashboardAuthConfig, loadOrCreateUnifiedConfig, - saveUnifiedConfig, + mutateUnifiedConfig, } from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; @@ -487,16 +487,16 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { return; } - const config = loadOrCreateUnifiedConfig(); - config.cliproxy.safety = { - ...(config.cliproxy.safety ?? {}), - antigravity_ack_bypass: antigravityAckBypass, - }; - saveUnifiedConfig(config); + const updatedConfig = mutateUnifiedConfig((config) => { + config.cliproxy.safety = { + ...(config.cliproxy.safety ?? {}), + antigravity_ack_bypass: antigravityAckBypass, + }; + }); res.json({ success: true, - antigravityAckBypass, + antigravityAckBypass: updatedConfig.cliproxy?.safety?.antigravity_ack_bypass === true, }); } catch (error) { const classified = classifyConfigSaveFailure(error); diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 4784056b..391a865d 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -6,6 +6,7 @@ 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 { @@ -18,6 +19,7 @@ import { Box, AlertTriangle, ShieldAlert, + ExternalLink, } from 'lucide-react'; import { useProxyConfig, useRawConfig } from '../../hooks'; 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 { 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 */ @@ -34,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, @@ -71,7 +78,11 @@ export default function ProxySection() { 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); @@ -99,17 +110,10 @@ export default function ProxySection() { } }, []); - const saveAgyAckBypass = useCallback( + const persistAgyAckBypass = useCallback( async (nextValue: boolean) => { 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 { agyAckBypassSavingRef.current = true; setAgyAckBypassSaving(true); @@ -120,12 +124,34 @@ export default function ProxySection() { body: JSON.stringify({ antigravityAckBypass: nextValue }), }); + const payload = (await response.json()) as { + antigravityAckBypass?: boolean; + error?: string; + }; + if (!response.ok) { - const data = (await response.json()) as { error?: string }; - throw new Error(data.error || 'Failed to update AGY power user mode'); + throw new Error(payload.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.'); await fetchRawConfig(); } catch (err) { @@ -138,6 +164,30 @@ export default function ProxySection() { [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); @@ -484,7 +534,7 @@ export default function ProxySection() { aria-describedby="agy-power-user-mode-description" checked={agyAckBypass} disabled={agyAckBypassLoading || agyAckBypassSaving || saving} - onCheckedChange={saveAgyAckBypass} + onCheckedChange={handleAgyAckBypassChange} />

+ {showAgyEnableConfirm && ( +

+
+

+ Final confirmation required +

+

+ Enabling this will skip AGY safety checkpoints in both dashboard and CLI. + Review issue #509 and type the exact phrase to proceed. +

+
+
+
+

+ Step 1 +

+ + Read issue #509 + + +
+
+

+ Step 2 +

+

+ Type{' '} + + {RISK_ACK_PHRASE} + {' '} + to enable. +

+
+
+
+ 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" + /> +

+ Exact phrase required. +

+
+
+ + +
+
+ )} Toggle AGY power user mode From 7d5e604e53b47811557baf4b1a6c4a11ed70af9a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 00:24:11 +0700 Subject: [PATCH 11/94] fix(auth): harden shared-context isolation edge cases --- src/auth/account-context.ts | 14 ++- src/auth/commands/create-command.ts | 99 ++++++++++++++++++++- src/auth/commands/list-command.ts | 2 + src/auth/commands/types.ts | 24 +++++ src/config/migration-manager.ts | 9 ++ src/management/instance-manager.ts | 79 ++++++++++++++-- src/web-server/routes/account-routes.ts | 23 +++-- tests/unit/account-context.test.ts | 31 +++++++ tests/unit/auth-command-args.test.ts | 14 +++ tests/unit/auth-list-context.test.ts | 88 ++++++++++++++++++ tests/unit/config/migration-manager.test.ts | 39 +++++++- tests/unit/shared-context-policy.test.ts | 16 ++++ 12 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 tests/unit/account-context.test.ts create mode 100644 tests/unit/auth-list-context.test.ts diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index 93cb29be..f97a89b5 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -29,6 +29,8 @@ export interface ResolvedCreateAccountContext { export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated'; export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default'; +export const MAX_CONTEXT_GROUP_LENGTH = 64; +export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; @@ -43,7 +45,14 @@ export function normalizeContextGroupName(value: string): string { * Validate context group naming constraints. */ export function isValidContextGroupName(value: string): boolean { - return CONTEXT_GROUP_PATTERN.test(value); + return value.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(value); +} + +/** + * Validate account profile naming constraints. + */ +export function isValidAccountProfileName(value: string): boolean { + return ACCOUNT_PROFILE_NAME_PATTERN.test(value); } /** @@ -84,8 +93,7 @@ export function resolveCreateAccountContext( if (!isValidContextGroupName(normalizedGroup)) { return { policy: { mode: 'isolated' }, - error: - 'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.', + error: `Invalid context group. Use letters/numbers/dash/underscore, start with a letter, max ${MAX_CONTEXT_GROUP_LENGTH} chars.`, }; } diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 84c04518..bc40fea4 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -9,21 +9,27 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../.. import { getClaudeCliInfo } from '../../utils/claude-detector'; import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor'; import { isUnifiedMode } from '../../config/unified-config-loader'; +import { ProfileMetadata } from '../../types'; import { resolveCreateAccountContext, policyToAccountContextMetadata, formatAccountContextPolicy, + isValidAccountProfileName, } from '../account-context'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; +function sanitizeProfileNameForInstance(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); +} + /** * Handle the create command */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force, shareContext, contextGroup } = parseArgs(args); + const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args); if (!profileName) { console.log(fail('Profile name is required')); @@ -37,6 +43,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } + if (unknownFlags && unknownFlags.length > 0) { + const unknownList = unknownFlags.join(', '); + console.log(fail(`Unknown option(s): ${unknownList}`)); + console.log(''); + exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); + } + + if (!isValidAccountProfileName(profileName)) { + const error = + 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.'; + console.log(fail(error)); + console.log(''); + exitWithError(error, ExitCode.PROFILE_ERROR); + } + // Check if profile already exists (check both legacy and unified) const existsLegacy = ctx.registry.hasProfile(profileName); const existsUnified = ctx.registry.hasAccountUnified(profileName); @@ -46,6 +67,18 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); } + const normalizedName = sanitizeProfileNameForInstance(profileName); + const collidingName = Object.keys(ctx.registry.getAllProfilesMerged()).find( + (name) => name !== profileName && sanitizeProfileNameForInstance(name) === normalizedName + ); + + if (collidingName) { + const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`; + console.log(fail(error)); + console.log(''); + exitWithError(error, ExitCode.PROFILE_ERROR); + } + const resolvedContext = resolveCreateAccountContext({ shareContext: !!shareContext, contextGroup, @@ -59,14 +92,47 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const contextPolicy = resolvedContext.policy; const contextMetadata = policyToAccountContextMetadata(contextPolicy); + const useUnifiedConfig = isUnifiedMode(); + const createdProfile = useUnifiedConfig ? !existsUnified : !existsLegacy; + const previousLegacyProfile: ProfileMetadata | undefined = + !useUnifiedConfig && existsLegacy ? ctx.registry.getProfile(profileName) : undefined; + const previousUnifiedProfile = + useUnifiedConfig && existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; try { + const rollbackMetadata = (): void => { + try { + if (useUnifiedConfig) { + if (createdProfile) { + if (ctx.registry.hasAccountUnified(profileName)) { + ctx.registry.removeAccountUnified(profileName); + } + } else if (previousUnifiedProfile) { + ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); + } + return; + } + + if (createdProfile) { + if (ctx.registry.hasProfile(profileName)) { + ctx.registry.deleteProfile(profileName); + } + } else if (previousLegacyProfile) { + ctx.registry.updateProfile(profileName, previousLegacyProfile); + } + } catch { + // Best-effort rollback to avoid leaving stale accounts after failed login. + } + }; + // Create instance directory console.log(info(`Creating profile: ${profileName}`)); const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy); // Create/update profile entry based on config mode - if (isUnifiedMode()) { + if (useUnifiedConfig) { // Use unified config (config.yaml) if (existsUnified) { ctx.registry.updateAccountUnified(profileName, { @@ -96,7 +162,11 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(info(`Instance directory: ${instancePath}`)); console.log(''); - console.log(warn('Starting Claude in isolated instance...')); + const launchDescription = + contextPolicy.mode === 'shared' + ? `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...` + : 'Starting Claude in isolated instance...'; + console.log(warn(launchDescription)); console.log(warn('You will be prompted to login with your account.')); console.log(''); @@ -112,6 +182,20 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const { path: claudeCli, needsShell } = claudeInfo; const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath }); + // Avoid ambient provider credentials influencing account-login bootstrap behavior. + const ambientProviderPrefixes = ['ANTHROPIC_', 'OPENAI_', 'GOOGLE_', 'GEMINI_', 'MINIMAX_']; + for (const envKey of Object.keys(childEnv)) { + if (envKey === 'CLAUDE_CONFIG_DIR') { + continue; + } + + if ( + ambientProviderPrefixes.some((prefix) => envKey.startsWith(prefix)) || + envKey === 'OPENROUTER_API_KEY' + ) { + delete childEnv[envKey]; + } + } // Execute Claude in isolated instance (will auto-prompt for login if no credentials) // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly @@ -162,6 +246,11 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(''); process.exit(0); } else { + rollbackMetadata(); + if (createdProfile) { + ctx.instanceMgr.deleteInstance(profileName); + } + console.log(''); console.log(fail('Login failed or cancelled')); console.log(''); @@ -173,6 +262,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise }); child.on('error', (err: Error) => { + rollbackMetadata(); + if (createdProfile) { + ctx.instanceMgr.deleteInstance(profileName); + } exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); }); } catch (error) { diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index a60dbff3..a248c2ee 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -30,6 +30,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise(); + const knownBooleanFlags = new Set([ + '--force', + '--verbose', + '--json', + '--yes', + '-y', + '--share-context', + ]); + const knownValueFlags = new Set(['--context-group']); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -83,6 +94,18 @@ export function parseArgs(args: string[]): AuthCommandArgs { } if (arg.startsWith('-')) { + const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; + const isKnownFlag = + knownBooleanFlags.has(normalizedFlag) || knownValueFlags.has(normalizedFlag); + if (!isKnownFlag) { + unknownFlags.add(normalizedFlag); + // Best effort: unknown flags often take a value token. + // Skip one following non-flag token to avoid mis-parsing profile name. + const next = args[i + 1]; + if (!arg.includes('=') && next && !next.startsWith('-')) { + i++; + } + } continue; } @@ -99,5 +122,6 @@ export function parseArgs(args: string[]): AuthCommandArgs { yes: args.includes('--yes') || args.includes('-y'), shareContext: args.includes('--share-context'), contextGroup, + unknownFlags: [...unknownFlags], }; } diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 3910a71a..2a94f78b 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -148,9 +148,18 @@ export async function migrate(dryRun = false): Promise { if (oldProfiles?.profiles) { for (const [name, meta] of Object.entries(oldProfiles.profiles)) { const metadata = meta as Record; + const rawContextMode = metadata.context_mode; + const rawContextGroup = metadata.context_group; + const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated'; + const contextGroup = + typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0 + ? rawContextGroup + : undefined; const account: AccountConfig = { created: (metadata.created as string) || new Date().toISOString(), last_used: (metadata.last_used as string) || null, + context_mode: contextMode, + context_group: contextMode === 'shared' ? contextGroup : undefined, }; unifiedConfig.accounts[name] = account; } diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index ab3889de..b51abae3 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -17,10 +17,12 @@ import { getCcsDir } from '../utils/config-manager'; */ class InstanceManager { private readonly instancesDir: string; + private readonly locksDir: string; private readonly sharedManager: SharedManager; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); + this.locksDir = path.join(this.instancesDir, '.locks'); this.sharedManager = new SharedManager(); } @@ -33,16 +35,19 @@ class InstanceManager { ): Promise { const instancePath = this.getInstancePath(profileName); - // Lazy initialization - if (!fs.existsSync(instancePath)) { - this.initializeInstance(profileName, instancePath); - } + // Serialize context sync operations per profile across processes. + await this.withContextSyncLock(profileName, async () => { + // Lazy initialization + if (!fs.existsSync(instancePath)) { + this.initializeInstance(profileName, instancePath); + } - // Validate structure (auto-fix missing dirs) - this.validateInstance(instancePath); + // Validate structure (auto-fix missing dirs) + this.validateInstance(instancePath); - // Apply context policy (isolated by default, optional shared group). - await this.sharedManager.syncProjectContext(instancePath, contextPolicy); + // Apply context policy (isolated by default, optional shared group). + await this.sharedManager.syncProjectContext(instancePath, contextPolicy); + }); return instancePath; } @@ -195,6 +200,64 @@ class InstanceManager { // Replace unsafe characters with dash return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); } + + private getContextSyncLockPath(profileName: string): string { + const safeName = this.sanitizeName(profileName); + return path.join(this.locksDir, `${safeName}.lock`); + } + + private async withContextSyncLock( + profileName: string, + callback: () => Promise + ): Promise { + const lockPath = this.getContextSyncLockPath(profileName); + const retryDelayMs = 50; + const timeoutMs = 5000; + const staleLockMs = 30000; + const start = Date.now(); + + fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(fd, `${process.pid}`); + fs.closeSync(fd); + break; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw error; + } + + try { + const lockStats = fs.statSync(lockPath); + if (Date.now() - lockStats.mtimeMs > staleLockMs) { + fs.unlinkSync(lockPath); + continue; + } + } catch { + // Best-effort stale lock cleanup. + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for profile context lock: ${profileName}`); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + try { + return await callback(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + // Best-effort cleanup. + } + } + } } export { InstanceManager }; diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 8c35128a..a92422aa 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; +import InstanceManager from '../../management/instance-manager'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { getAllAccountsSummary, @@ -21,19 +22,25 @@ import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; const router = Router(); const registry = new ProfileRegistry(); +const instanceMgr = new InstanceManager(); /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { - const colonIndex = key.indexOf(':'); + const normalizedKey = key.startsWith('cliproxy:') ? key.slice('cliproxy:'.length) : key; + const colonIndex = normalizedKey.indexOf(':'); if (colonIndex === -1) return null; - const provider = key.slice(0, colonIndex); - const accountId = key.slice(colonIndex + 1); + const provider = normalizedKey.slice(0, colonIndex); + const accountId = normalizedKey.slice(colonIndex + 1); if (!isCLIProxyProvider(provider) || !accountId) return null; return { provider, accountId }; } +function hasAuthAccount(name: string): boolean { + return registry.hasAccountUnified(name) || registry.hasProfile(name); +} + /** * GET /api/accounts - List accounts from both profiles.json and config.yaml */ @@ -91,7 +98,8 @@ router.get('/', (_req: Request, res: Response): void => { } // Use unique ID for key to prevent collisions between accounts with same nickname/email const displayName = acct.nickname || acct.email || acct.id; - const key = `${provider}:${acct.id}`; + const rawKey = `${provider}:${acct.id}`; + const key = merged[rawKey] ? `cliproxy:${rawKey}` : rawKey; merged[key] = { type: 'cliproxy', provider, @@ -130,7 +138,7 @@ router.post('/default', (req: Request, res: Response): void => { } // Check if this is a CLIProxy account (format: "provider:accountId") - const cliproxyKey = parseCliproxyKey(name); + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; if (cliproxyKey) { const success = setCliproxyDefault(cliproxyKey.provider, cliproxyKey.accountId); if (!success) { @@ -192,7 +200,7 @@ router.delete('/:name', (req: Request, res: Response): void => { } // Check if this is a CLIProxy account (format: "provider:accountId") - const cliproxyKey = parseCliproxyKey(name); + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; if (cliproxyKey) { const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId); if (!success) { @@ -219,6 +227,9 @@ router.delete('/:name', (req: Request, res: Response): void => { return; } + // Keep API delete behavior aligned with CLI remove command. + instanceMgr.deleteInstance(name); + res.json({ success: true, deleted: name }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts new file mode 100644 index 00000000..8b18b55d --- /dev/null +++ b/tests/unit/account-context.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; +import { + MAX_CONTEXT_GROUP_LENGTH, + isValidAccountProfileName, + resolveAccountContextPolicy, + resolveCreateAccountContext, +} from '../../src/auth/account-context'; + +describe('account context helpers', () => { + it('rejects context groups that exceed the max length', () => { + const group = `a${'x'.repeat(MAX_CONTEXT_GROUP_LENGTH)}`; + const result = resolveCreateAccountContext({ shareContext: false, contextGroup: group }); + + expect(result.error).toContain('Invalid context group'); + }); + + it('rejects profile names with unsupported characters', () => { + expect(isValidAccountProfileName('work')).toBe(true); + expect(isValidAccountProfileName('gemini:default')).toBe(false); + }); + + it('falls back to default shared group for invalid persisted metadata', () => { + const resolved = resolveAccountContextPolicy({ + context_mode: 'shared', + context_group: '###', + }); + + expect(resolved.mode).toBe('shared'); + expect(resolved.group).toBe('default'); + }); +}); diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index e91afc9c..e6ba81e7 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -31,4 +31,18 @@ describe('auth command args parsing', () => { expect(parsed.profileName).toBe('work'); expect(parsed.contextGroup).toBe(''); }); + + it('flags empty inline context group as empty string', () => { + const parsed = parseArgs(['work', '--context-group=']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.contextGroup).toBe(''); + }); + + it('tracks unknown flags and keeps positional profile intact', () => { + const parsed = parseArgs(['--foo', 'bar', 'work']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.unknownFlags).toEqual(['--foo']); + }); }); diff --git a/tests/unit/auth-list-context.test.ts b/tests/unit/auth-list-context.test.ts new file mode 100644 index 00000000..7f24b531 --- /dev/null +++ b/tests/unit/auth-list-context.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../src/auth/profile-registry'; +import InstanceManager from '../../src/management/instance-manager'; +import { handleList } from '../../src/auth/commands/list-command'; + +describe('auth list context metadata', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsUnified: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-list-context-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsUnified = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempRoot; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('keeps unified account context metadata in JSON list output', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + + const instanceMgr = new InstanceManager(); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await handleList( + { + registry, + instanceMgr, + version: 'test', + }, + ['--json'] + ); + } finally { + console.log = originalLog; + } + + const payload = JSON.parse(lines.join('\n')) as { + profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>; + }; + const work = payload.profiles.find((profile) => profile.name === 'work'); + + expect(work).toBeTruthy(); + expect(work?.context_mode).toBe('shared'); + expect(work?.context_group).toBe('sprint-a'); + }); +}); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 450640d9..ec446d35 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager'; -import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; describe('migration-manager legacy kimi compatibility', () => { @@ -132,4 +132,41 @@ describe('migration-manager legacy kimi compatibility', () => { const checkData = loadMigrationCheckData(); expect(checkData.needsMigration).toBe(false); }); + + it('migrates account context metadata from profiles.json', async () => { + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + default: 'work', + profiles: { + work: { + type: 'account', + created: '2026-02-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: 'sprint-a', + }, + personal: { + type: 'account', + created: '2026-02-02T00:00:00.000Z', + last_used: null, + }, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(false); + expect(result.success).toBe(true); + + const unified = loadUnifiedConfig(); + expect(unified).toBeTruthy(); + expect(unified?.accounts.work.context_mode).toBe('shared'); + expect(unified?.accounts.work.context_group).toBe('sprint-a'); + expect(unified?.accounts.personal.context_mode).toBe('isolated'); + expect(unified?.accounts.personal.context_group).toBeUndefined(); + }); }); diff --git a/tests/unit/shared-context-policy.test.ts b/tests/unit/shared-context-policy.test.ts index 5cf76b09..dffbd37f 100644 --- a/tests/unit/shared-context-policy.test.ts +++ b/tests/unit/shared-context-policy.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import SharedManager from '../../src/management/shared-manager'; +import InstanceManager from '../../src/management/instance-manager'; import type { AccountContextPolicy } from '../../src/auth/account-context'; function getTestCcsDir(): string { @@ -118,4 +119,19 @@ describe('SharedManager context policy', () => { expect(fs.existsSync(projectFile)).toBe(true); expect(fs.readFileSync(projectFile, 'utf8')).toBe('shared history'); }); + + it('serializes concurrent context sync for the same profile', async () => { + const instanceMgr = new InstanceManager(); + const jobs = Array.from({ length: 6 }, () => + instanceMgr.ensureInstance('work', { mode: 'shared', group: 'sprint-a' }) + ); + + await Promise.all(jobs); + + const ccsDir = getTestCcsDir(); + const projectsPath = path.join(ccsDir, 'instances', 'work', 'projects'); + const stats = fs.lstatSync(projectsPath); + + expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true); + }); }); From 0b070a3f343846cb8082fcd351f0bcb3fc2f109d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 00:55:35 +0700 Subject: [PATCH 12/94] fix(auth): close shared-context isolation edge cases --- src/auth/commands/create-command.ts | 225 +++++++++++------- src/auth/profile-registry.ts | 14 +- src/config/migration-manager.ts | 16 +- src/management/instance-manager.ts | 41 +++- src/management/shared-manager.ts | 28 +++ .../routes/account-route-helpers.ts | 45 ++++ src/web-server/routes/account-routes.ts | 57 ++--- src/web-server/routes/config-routes.ts | 54 +++++ tests/unit/account-context.test.ts | 12 + tests/unit/auth-list-context.test.ts | 74 ++++++ tests/unit/config/migration-manager.test.ts | 42 ++++ .../web-server/account-routes-context.test.ts | 133 +++++++++++ .../config-routes-account-context.test.ts | 141 +++++++++++ .../account/create-auth-profile-dialog.tsx | 8 +- 14 files changed, 767 insertions(+), 123 deletions(-) create mode 100644 src/web-server/routes/account-route-helpers.ts create mode 100644 tests/unit/web-server/account-routes-context.test.ts create mode 100644 tests/unit/web-server/config-routes-account-context.test.ts diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index bc40fea4..614a0ec8 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -15,6 +15,7 @@ import { policyToAccountContextMetadata, formatAccountContextPolicy, isValidAccountProfileName, + resolveAccountContextPolicy, } from '../account-context'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; @@ -24,6 +25,47 @@ function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); } +const AMBIENT_PROVIDER_PREFIXES = [ + 'ANTHROPIC_', + 'OPENAI_', + 'GOOGLE_', + 'GEMINI_', + 'MINIMAX_', + 'QWEN_', + 'DEEPSEEK_', + 'KIMI_', + 'AZURE_', + 'OLLAMA_', +]; +const AMBIENT_PROVIDER_EXACT_KEYS = new Set([ + 'OPENROUTER_API_KEY', + 'OPENROUTER_KEY', + 'XAI_API_KEY', + 'MISTRAL_API_KEY', + 'COHERE_API_KEY', +]); +const AMBIENT_PROVIDER_SUFFIXES = ['_API_KEY', '_AUTH_TOKEN', '_ACCESS_TOKEN', '_SECRET_KEY']; + +function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sanitized: NodeJS.ProcessEnv = { ...env }; + + for (const envKey of Object.keys(sanitized)) { + if (envKey === 'CLAUDE_CONFIG_DIR') { + continue; + } + + if ( + AMBIENT_PROVIDER_PREFIXES.some((prefix) => envKey.startsWith(prefix)) || + AMBIENT_PROVIDER_EXACT_KEYS.has(envKey) || + AMBIENT_PROVIDER_SUFFIXES.some((suffix) => envKey.endsWith(suffix)) + ) { + delete sanitized[envKey]; + } + } + + return sanitized; +} + /** * Handle the create command */ @@ -31,6 +73,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise await initUI(); const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args); + if (unknownFlags && unknownFlags.length > 0) { + const unknownList = unknownFlags.join(', '); + console.log(fail(`Unknown option(s): ${unknownList}`)); + console.log(''); + exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); + } + if (!profileName) { console.log(fail('Profile name is required')); console.log(''); @@ -43,13 +92,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); } - if (unknownFlags && unknownFlags.length > 0) { - const unknownList = unknownFlags.join(', '); - console.log(fail(`Unknown option(s): ${unknownList}`)); - console.log(''); - exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); - } - if (!isValidAccountProfileName(profileName)) { const error = 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.'; @@ -100,33 +142,73 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise useUnifiedConfig && existsUnified ? ctx.registry.getAllAccountsUnified()[profileName] : undefined; + const previousContextPolicy = + !createdProfile && (previousUnifiedProfile || previousLegacyProfile) + ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) + : undefined; + + const claudeInfo = getClaudeCliInfo(); + if (!claudeInfo) { + console.log(fail('Claude CLI not found')); + console.log(''); + console.log('Please install Claude CLI first:'); + console.log(` ${color('https://claude.ai/download', 'path')}`); + exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); + } + + let rollbackCompleted = false; + const rollbackMetadata = (): void => { + try { + if (useUnifiedConfig) { + if (createdProfile) { + if (ctx.registry.hasAccountUnified(profileName)) { + ctx.registry.removeAccountUnified(profileName); + } + } else if (previousUnifiedProfile) { + ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); + } + return; + } + + if (createdProfile) { + if (ctx.registry.hasProfile(profileName)) { + ctx.registry.deleteProfile(profileName); + } + } else if (previousLegacyProfile) { + ctx.registry.updateProfile(profileName, previousLegacyProfile); + } + } catch { + // Best-effort rollback to avoid leaving stale accounts after failed login. + } + }; + + const rollbackFailedCreate = async (): Promise => { + if (rollbackCompleted) { + return; + } + rollbackCompleted = true; + + rollbackMetadata(); + + if (createdProfile) { + try { + ctx.instanceMgr.deleteInstance(profileName); + } catch { + // Best-effort cleanup. + } + return; + } + + if (previousContextPolicy) { + try { + await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy); + } catch { + // Best-effort rollback for context mode/group. + } + } + }; try { - const rollbackMetadata = (): void => { - try { - if (useUnifiedConfig) { - if (createdProfile) { - if (ctx.registry.hasAccountUnified(profileName)) { - ctx.registry.removeAccountUnified(profileName); - } - } else if (previousUnifiedProfile) { - ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); - } - return; - } - - if (createdProfile) { - if (ctx.registry.hasProfile(profileName)) { - ctx.registry.deleteProfile(profileName); - } - } else if (previousLegacyProfile) { - ctx.registry.updateProfile(profileName, previousLegacyProfile); - } - } catch { - // Best-effort rollback to avoid leaving stale accounts after failed login. - } - }; - // Create instance directory console.log(info(`Creating profile: ${profileName}`)); const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy); @@ -170,53 +252,39 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(warn('You will be prompted to login with your account.')); console.log(''); - // Detect Claude CLI - const claudeInfo = getClaudeCliInfo(); - if (!claudeInfo) { - console.log(fail('Claude CLI not found')); - console.log(''); - console.log('Please install Claude CLI first:'); - console.log(` ${color('https://claude.ai/download', 'path')}`); - exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR); - } - const { path: claudeCli, needsShell } = claudeInfo; - const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath }); - // Avoid ambient provider credentials influencing account-login bootstrap behavior. - const ambientProviderPrefixes = ['ANTHROPIC_', 'OPENAI_', 'GOOGLE_', 'GEMINI_', 'MINIMAX_']; - for (const envKey of Object.keys(childEnv)) { - if (envKey === 'CLAUDE_CONFIG_DIR') { - continue; - } - - if ( - ambientProviderPrefixes.some((prefix) => envKey.startsWith(prefix)) || - envKey === 'OPENROUTER_API_KEY' - ) { - delete childEnv[envKey]; - } - } + const childEnv = stripAmbientProviderCredentials( + stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath }) + ); // Execute Claude in isolated instance (will auto-prompt for login if no credentials) // On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly let child: ChildProcess; - if (needsShell) { - const cmdString = escapeShellArg(claudeCli); - child = spawn(cmdString, { - stdio: 'inherit', - windowsHide: true, - shell: true, - env: childEnv, - }); - } else { - child = spawn(claudeCli, [], { - stdio: 'inherit', - windowsHide: true, - env: childEnv, - }); + try { + if (needsShell) { + const cmdString = escapeShellArg(claudeCli); + child = spawn(cmdString, { + stdio: 'inherit', + windowsHide: true, + shell: true, + env: childEnv, + }); + } else { + child = spawn(claudeCli, [], { + stdio: 'inherit', + windowsHide: true, + env: childEnv, + }); + } + } catch (error) { + await rollbackFailedCreate(); + exitWithError( + `Failed to execute Claude CLI: ${(error as Error).message}`, + ExitCode.BINARY_ERROR + ); } - child.on('exit', (code: number | null) => { + child.on('exit', async (code: number | null) => { if (code === 0) { console.log(''); console.log( @@ -246,10 +314,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise console.log(''); process.exit(0); } else { - rollbackMetadata(); - if (createdProfile) { - ctx.instanceMgr.deleteInstance(profileName); - } + await rollbackFailedCreate(); console.log(''); console.log(fail('Login failed or cancelled')); @@ -261,14 +326,12 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } }); - child.on('error', (err: Error) => { - rollbackMetadata(); - if (createdProfile) { - ctx.instanceMgr.deleteInstance(profileName); - } + child.on('error', async (err: Error) => { + await rollbackFailedCreate(); exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR); }); } catch (error) { + await rollbackFailedCreate(); exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR); } } diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e6d7beda..5870db61 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -159,7 +159,7 @@ export class ProfileRegistry { throw new Error(`Profile not found: ${name}`); } - return data.profiles[name]; + return this.normalizeLegacyProfileMetadata(data.profiles[name]); } /** @@ -215,7 +215,11 @@ export class ProfileRegistry { */ getAllProfiles(): Record { const data = this._read(); - return data.profiles; + const normalized: Record = {}; + for (const [name, profile] of Object.entries(data.profiles)) { + normalized[name] = this.normalizeLegacyProfileMetadata(profile); + } + return normalized; } /** @@ -357,7 +361,11 @@ export class ProfileRegistry { getAllAccountsUnified(): Record { if (!isUnifiedMode()) return {}; const config = loadOrCreateUnifiedConfig(); - return config.accounts; + const normalized: Record = {}; + for (const [name, account] of Object.entries(config.accounts)) { + normalized[name] = this.normalizeUnifiedAccountConfig(account); + } + return normalized; } /** diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 2a94f78b..cf9628d2 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -21,6 +21,7 @@ import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unif import { createEmptyUnifiedConfig } from './unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; +import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -151,10 +152,17 @@ export async function migrate(dryRun = false): Promise { const rawContextMode = metadata.context_mode; const rawContextGroup = metadata.context_group; const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated'; - const contextGroup = - typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0 - ? rawContextGroup - : undefined; + let contextGroup: string | undefined; + if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) { + const normalizedGroup = normalizeContextGroupName(rawContextGroup); + if (isValidContextGroupName(normalizedGroup)) { + contextGroup = normalizedGroup; + } else { + warnings.push( + `Skipped invalid context group for account "${name}": "${rawContextGroup}" (fallback to default shared group)` + ); + } + } const account: AccountConfig = { created: (metadata.created as string) || new Date().toISOString(), last_used: (metadata.last_used as string) || null, diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b51abae3..e39acbb1 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { createHash } from 'crypto'; import SharedManager from './shared-manager'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -203,7 +204,39 @@ class InstanceManager { private getContextSyncLockPath(profileName: string): string { const safeName = this.sanitizeName(profileName); - return path.join(this.locksDir, `${safeName}.lock`); + // Keep lock filenames deterministic while preventing normalized-name collisions. + const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8); + return path.join(this.locksDir, `${safeName}-${profileHash}.lock`); + } + + private isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + return true; + } + return false; + } + } + + private tryRemoveDeadOwnerLock(lockPath: string): boolean { + try { + const lockContent = fs.readFileSync(lockPath, 'utf8').trim(); + const lockPid = Number.parseInt(lockContent, 10); + if (!this.isProcessAlive(lockPid)) { + fs.unlinkSync(lockPath); + return true; + } + } catch { + // Best-effort stale lock cleanup. + } + return false; } private async withContextSyncLock( @@ -212,8 +245,8 @@ class InstanceManager { ): Promise { const lockPath = this.getContextSyncLockPath(profileName); const retryDelayMs = 50; - const timeoutMs = 5000; const staleLockMs = 30000; + const timeoutMs = staleLockMs + 5000; const start = Date.now(); fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); @@ -236,6 +269,10 @@ class InstanceManager { fs.unlinkSync(lockPath); continue; } + + if (this.tryRemoveDeadOwnerLock(lockPath)) { + continue; + } } catch { // Best-effort stale lock cleanup. } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index b48aeb54..e2ae5ad8 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -240,6 +240,7 @@ class SharedManager { if ( currentTarget && path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) && + this.isSafeProjectsMergeSource(currentTarget, instanceName) && (await this.pathExists(currentTarget)) ) { await this.mergeDirectoryWithConflictCopies( @@ -247,6 +248,10 @@ class SharedManager { sharedProjectsPath, instanceName ); + } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { + console.log( + warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) + ); } await fs.promises.unlink(projectsPath); @@ -286,9 +291,14 @@ class SharedManager { if ( currentTarget && path.resolve(currentTarget) !== path.resolve(projectsPath) && + this.isSafeProjectsMergeSource(currentTarget, instanceName) && (await this.pathExists(currentTarget)) ) { await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName); + } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { + console.log( + warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) + ); } return; @@ -678,6 +688,24 @@ class SharedManager { } } + /** + * Guard project merge operations to known CCS-managed roots only. + */ + private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean { + const resolvedSource = path.resolve(sourcePath); + const sharedContextRoot = path.resolve(path.join(this.sharedDir, 'context-groups')); + const instanceProjectsRoot = path.resolve( + path.join(this.instancesDir, instanceName, 'projects') + ); + + const isWithin = (child: string, root: string): boolean => + child === root || child.startsWith(`${root}${path.sep}`); + + return ( + isWithin(resolvedSource, sharedContextRoot) || isWithin(resolvedSource, instanceProjectsRoot) + ); + } + /** * Link directory with Windows fallback to recursive copy. */ diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts new file mode 100644 index 00000000..645262be --- /dev/null +++ b/src/web-server/routes/account-route-helpers.ts @@ -0,0 +1,45 @@ +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; + +export interface MergedAccountEntry { + type: string; + created: string; + last_used: string | null; + context_mode?: 'isolated' | 'shared'; + context_group?: string; + provider?: string; + displayName?: string; +} + +/** Parse CLIProxy account key format: "provider:accountId" */ +export function parseCliproxyKey( + key: string +): { provider: CLIProxyProvider; accountId: string } | null { + let normalizedKey = key; + if (key.startsWith('cliproxy:')) { + normalizedKey = key.slice('cliproxy:'.length); + } else if (key.startsWith('cliproxy+')) { + normalizedKey = key.slice('cliproxy+'.length); + } + const colonIndex = normalizedKey.indexOf(':'); + if (colonIndex === -1) return null; + + const provider = normalizedKey.slice(0, colonIndex); + const accountId = normalizedKey.slice(colonIndex + 1); + + if (!isCLIProxyProvider(provider) || !accountId) return null; + return { provider, accountId }; +} + +export function buildCliproxyAccountKey( + rawKey: string, + merged: Record +): string | null { + const candidateKeys = [rawKey, `cliproxy:${rawKey}`, `cliproxy+${rawKey}`]; + for (const key of candidateKeys) { + if (!merged[key]) { + return key; + } + } + return null; +} diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index a92422aa..90ace229 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -12,31 +12,24 @@ import { isUnifiedMode } from '../../config/unified-config-loader'; import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, + getDefaultAccount as getCliproxyDefaultAccount, removeAccount as removeCliproxyAccount, bulkPauseAccounts, bulkResumeAccounts, soloAccount, } from '../../cliproxy/account-manager'; -import type { CLIProxyProvider } from '../../cliproxy/types'; import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; +import { resolveAccountContextPolicy } from '../../auth/account-context'; +import { + buildCliproxyAccountKey, + parseCliproxyKey, + type MergedAccountEntry, +} from './account-route-helpers'; const router = Router(); const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); -/** Parse CLIProxy account key format: "provider:accountId" */ -function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { - const normalizedKey = key.startsWith('cliproxy:') ? key.slice('cliproxy:'.length) : key; - const colonIndex = normalizedKey.indexOf(':'); - if (colonIndex === -1) return null; - - const provider = normalizedKey.slice(0, colonIndex); - const accountId = normalizedKey.slice(colonIndex + 1); - - if (!isCLIProxyProvider(provider) || !accountId) return null; - return { provider, accountId }; -} - function hasAuthAccount(name: string): boolean { return registry.hasAccountUnified(name) || registry.hasProfile(name); } @@ -54,38 +47,29 @@ router.get('/', (_req: Request, res: Response): void => { const cliproxyAccounts = getAllAccountsSummary(); // Merge profiles: unified config takes precedence - const merged: Record< - string, - { - type: string; - created: string; - last_used: string | null; - context_mode?: 'isolated' | 'shared'; - context_group?: string; - provider?: string; - displayName?: string; - } - > = {}; + const merged: Record = {}; // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { + const contextPolicy = resolveAccountContextPolicy(meta); merged[name] = { type: meta.type || 'account', created: meta.created, last_used: meta.last_used || null, - context_mode: meta.context_mode, - context_group: meta.context_group, + context_mode: contextPolicy.mode, + context_group: contextPolicy.group, }; } // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { + const contextPolicy = resolveAccountContextPolicy(account); merged[name] = { type: 'account', created: account.created, last_used: account.last_used, - context_mode: account.context_mode, - context_group: account.context_group, + context_mode: contextPolicy.mode, + context_group: contextPolicy.group, }; } @@ -99,7 +83,10 @@ router.get('/', (_req: Request, res: Response): void => { // Use unique ID for key to prevent collisions between accounts with same nickname/email const displayName = acct.nickname || acct.email || acct.id; const rawKey = `${provider}:${acct.id}`; - const key = merged[rawKey] ? `cliproxy:${rawKey}` : rawKey; + const key = buildCliproxyAccountKey(rawKey, merged); + if (!key) { + continue; + } merged[key] = { type: 'cliproxy', provider, @@ -202,6 +189,14 @@ router.delete('/:name', (req: Request, res: Response): void => { // Check if this is a CLIProxy account (format: "provider:accountId") const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; if (cliproxyKey) { + const defaultCliproxyAccount = getCliproxyDefaultAccount(cliproxyKey.provider); + if (defaultCliproxyAccount?.id === cliproxyKey.accountId) { + res.status(400).json({ + error: `Cannot delete default CLIProxy account: ${name}. Set another default first.`, + }); + return; + } + const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId); if (!success) { res.status(404).json({ error: `CLIProxy account not found: ${name}` }); diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index d00985ca..537f7348 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -18,9 +18,57 @@ import { getBackupDirectories, } from '../../config/migration-manager'; import { isUnifiedConfig } from '../../config/unified-config-types'; +import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/account-context'; const router = Router(); +function validateAccountContextMetadata(config: unknown): string | null { + if (typeof config !== 'object' || config === null) { + return 'Invalid config payload'; + } + + const candidate = config as Record; + const accounts = candidate.accounts; + if (accounts === undefined) { + return null; + } + + if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) { + return 'Invalid config.accounts: expected object'; + } + + for (const [accountName, accountValue] of Object.entries(accounts as Record)) { + if (typeof accountValue !== 'object' || accountValue === null || Array.isArray(accountValue)) { + return `Invalid config.accounts.${accountName}: expected object`; + } + + const account = accountValue as Record; + const mode = account.context_mode; + const group = account.context_group; + + if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') { + return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`; + } + + if (group !== undefined && typeof group !== 'string') { + return `Invalid config.accounts.${accountName}.context_group: expected string`; + } + + if (mode !== 'shared' && group !== undefined) { + return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`; + } + + if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) { + const normalizedGroup = normalizeContextGroupName(group); + if (!isValidContextGroupName(normalizedGroup)) { + return `Invalid config.accounts.${accountName}.context_group`; + } + } + } + + return null; +} + /** * GET /api/config/format - Return current config format and migration status */ @@ -82,6 +130,12 @@ router.put('/', (req: Request, res: Response): void => { return; } + const accountContextError = validateAccountContextMetadata(config); + if (accountContextError) { + res.status(400).json({ error: accountContextError }); + return; + } + try { saveUnifiedConfig(config); res.json({ success: true }); diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index 8b18b55d..7e282a58 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { MAX_CONTEXT_GROUP_LENGTH, isValidAccountProfileName, + policyToAccountContextMetadata, resolveAccountContextPolicy, resolveCreateAccountContext, } from '../../src/auth/account-context'; @@ -28,4 +29,15 @@ describe('account context helpers', () => { expect(resolved.mode).toBe('shared'); expect(resolved.group).toBe('default'); }); + + it('round-trips shared policy metadata with normalized context group', () => { + const metadata = policyToAccountContextMetadata({ + mode: 'shared', + group: 'Sprint-A', + }); + + const resolved = resolveAccountContextPolicy(metadata); + expect(resolved.mode).toBe('shared'); + expect(resolved.group).toBe('sprint-a'); + }); }); diff --git a/tests/unit/auth-list-context.test.ts b/tests/unit/auth-list-context.test.ts index 7f24b531..b62cb798 100644 --- a/tests/unit/auth-list-context.test.ts +++ b/tests/unit/auth-list-context.test.ts @@ -85,4 +85,78 @@ describe('auth list context metadata', () => { expect(work?.context_mode).toBe('shared'); expect(work?.context_group).toBe('sprint-a'); }); + + it('prefers unified context metadata over legacy when profile names overlap', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + version: '2.0.0', + profiles: { + work: { + type: 'account', + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'isolated', + }, + }, + default: null, + }, + null, + 2 + ) + ); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await handleList( + { + registry, + instanceMgr, + version: 'test', + }, + ['--json'] + ); + } finally { + console.log = originalLog; + } + + const payload = JSON.parse(lines.join('\n')) as { + profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>; + }; + const work = payload.profiles.find((profile) => profile.name === 'work'); + + expect(work).toBeTruthy(); + expect(work?.context_mode).toBe('shared'); + expect(work?.context_group).toBe('sprint-a'); + }); }); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index ec446d35..1e09fcaf 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -169,4 +169,46 @@ describe('migration-manager legacy kimi compatibility', () => { expect(unified?.accounts.personal.context_mode).toBe('isolated'); expect(unified?.accounts.personal.context_group).toBeUndefined(); }); + + it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => { + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + default: 'work', + profiles: { + work: { + type: 'account', + created: '2026-02-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: 'Sprint-A', + }, + broken: { + type: 'account', + created: '2026-02-02T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: '###', + }, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(false); + expect(result.success).toBe(true); + expect( + result.warnings.some((warning) => + warning.includes('Skipped invalid context group for account "broken"') + ) + ).toBe(true); + + const unified = loadUnifiedConfig(); + expect(unified?.accounts.work.context_group).toBe('sprint-a'); + expect(unified?.accounts.broken.context_mode).toBe('shared'); + expect(unified?.accounts.broken.context_group).toBeUndefined(); + }); }); diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts new file mode 100644 index 00000000..0563c129 --- /dev/null +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -0,0 +1,133 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import accountRoutes from '../../../src/web-server/routes/account-routes'; + +async function getJson(baseUrl: string, routePath: string): Promise { + const response = await fetch(`${baseUrl}${routePath}`); + expect(response.status).toBe(200); + return (await response.json()) as T; +} + +describe('web-server account-routes context normalization', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalCcsUnified: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use('/api/accounts', accountRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-routes-context-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsUnified = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempHome; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('normalizes invalid persisted account context metadata in API response', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: weird', + ' context_group: "###"', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; + }>(baseUrl, '/api/accounts'); + + const work = payload.accounts.find((account) => account.name === 'work'); + expect(work).toBeTruthy(); + expect(work?.context_mode).toBe('isolated'); + expect(work && 'context_group' in work).toBe(false); + }); + + it('falls back shared accounts with invalid groups to default shared group', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: "###"', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; + }>(baseUrl, '/api/accounts'); + + const work = payload.accounts.find((account) => account.name === 'work'); + expect(work).toBeTruthy(); + expect(work?.context_mode).toBe('shared'); + expect(work?.context_group).toBe('default'); + }); +}); diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts new file mode 100644 index 00000000..850f654f --- /dev/null +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -0,0 +1,141 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import configRoutes from '../../../src/web-server/routes/config-routes'; +import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; + +async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + +describe('web-server config-routes account context validation', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/config', configRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + server.once('error', handleError); + server.once('listening', () => { + server.off('error', handleError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('rejects invalid account context_mode values', async () => { + const response = await putJson(baseUrl, '/api/config', { + version: 8, + accounts: { + work: { + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'weird', + }, + }, + profiles: {}, + cliproxy: { oauth_accounts: {}, providers: [], variants: {} }, + }); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('context_mode'); + }); + + it('rejects context_group when mode is not shared', async () => { + const response = await putJson(baseUrl, '/api/config', { + version: 8, + accounts: { + work: { + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'isolated', + context_group: 'sprint-a', + }, + }, + profiles: {}, + cliproxy: { oauth_accounts: {}, providers: [], variants: {} }, + }); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('context_group requires context_mode=shared'); + }); + + it('rejects invalid shared context_group names', async () => { + const response = await putJson(baseUrl, '/api/config', { + version: 8, + accounts: { + work: { + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: '###', + }, + }, + profiles: {}, + cliproxy: { oauth_accounts: {}, providers: [], variants: {} }, + }); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('context_group'); + }); + + it('accepts valid shared context metadata', async () => { + const config = createEmptyUnifiedConfig(); + config.accounts.work = { + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: 'Sprint-A', + }; + const response = await putJson(baseUrl, '/api/config', config); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { success: boolean }; + expect(payload.success).toBe(true); + }); +}); diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index a82dc72f..51b7b997 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -22,6 +22,8 @@ interface CreateAuthProfileDialogProps { onClose: () => void; } +const MAX_CONTEXT_GROUP_LENGTH = 64; + export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) { const [profileName, setProfileName] = useState(''); const [shareContext, setShareContext] = useState(false); @@ -32,7 +34,9 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); const normalizedGroup = contextGroup.trim().toLowerCase(); const isValidContextGroup = - normalizedGroup.length === 0 || /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup); + normalizedGroup.length === 0 || + (normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH && + /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup)); const command = profileName && isValidName @@ -119,7 +123,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial {contextGroup.trim().length > 0 && !isValidContextGroup && (

Group must start with a letter and use only letters, numbers, dashes, or - underscores. + underscores (max {MAX_CONTEXT_GROUP_LENGTH} chars).

)}
From 0309630193a222c0d03dbcdad749101af1ee5433 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 04:19:28 +0700 Subject: [PATCH 13/94] fix(auth): harden shared-context isolation and config safety --- README.md | 36 +++- docs/codebase-summary.md | 17 +- docs/dashboard-auth-cli.md | 35 +++- src/auth/account-context.ts | 2 +- src/auth/auth-commands.ts | 5 + src/auth/commands/create-command-env.ts | 57 ++++++ src/auth/commands/create-command.ts | 86 +++------ src/auth/profile-registry.ts | 32 +++- src/management/instance-manager.ts | 102 +--------- src/management/profile-context-sync-lock.ts | 177 ++++++++++++++++++ src/management/shared-manager.ts | 58 ++++-- src/web-server/routes/account-routes.ts | 23 ++- src/web-server/routes/config-routes.ts | 22 ++- tests/unit/account-context.test.ts | 11 ++ ...ile-registry-context-normalization.test.ts | 92 +++++++++ tests/unit/shared-context-policy.test.ts | 44 +++++ .../web-server/account-routes-context.test.ts | 43 +++++ .../config-routes-account-context.test.ts | 50 +++++ 18 files changed, 695 insertions(+), 197 deletions(-) create mode 100644 src/auth/commands/create-command-env.ts create mode 100644 src/management/profile-context-sync-lock.ts create mode 100644 tests/unit/auth/profile-registry-context-normalization.test.ts diff --git a/README.md b/README.md index 9c173863..a50d7a6f 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Want to run the dashboard in Docker? See `docker/README.md`. The dashboard provides visual management for all account types: -- **Claude Accounts**: Create isolated instances (work, personal, client) +- **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys - **Health Monitor**: Real-time status across all profiles @@ -221,17 +221,45 @@ ccs work "implement feature" # Terminal 1 ccs "review code" # Terminal 2 (personal account) ``` -Need continuity between two accounts for the same project? Opt in to shared context: +#### Account Context Modes (Isolation-First) + +Account profiles are isolated by default. + +| Mode | Default | Requirements | +|------|---------|--------------| +| `isolated` | Yes | No `context_group` required | +| `shared` | No (explicit opt-in) | Valid non-empty `context_group` | + +Opt in to shared context when needed: ```bash # Share context with default group ccs auth create backup --share-context -# Or isolate by named group (only accounts in this group share context) +# Share context only within named group ccs auth create backup2 --context-group sprint-a ``` -Isolation remains the default. Shared context only links project workspace data; credentials stay per-account. +Shared mode metadata in `~/.ccs/config.yaml`: + +```yaml +accounts: + work: + created: "2026-02-24T00:00:00.000Z" + last_used: null + context_mode: "shared" + context_group: "team-alpha" +``` + +`context_group` rules: + +- lowercase letters, numbers, `_`, `-` +- must start with a letter +- max length `64` +- non-empty after normalization +- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`) + +Shared context links project workspace data only. Credentials remain isolated per account.
diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 972b56c6..d138f34b 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-02-04 +Last Updated: 2026-02-24 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, and v7.34 Image Analysis Hook. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. ## Repository Structure @@ -201,6 +201,19 @@ src/ | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | +### Account Context Metadata Flow + +- Source fields: `accounts..context_mode` and `accounts..context_group` in `~/.ccs/config.yaml`. +- Runtime policy resolver: `src/auth/account-context.ts`. +- Metadata storage normalization: `src/auth/profile-registry.ts`. +- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`. +- Rules: + - mode is isolation-first (`isolated` default, `shared` opt-in) + - shared mode requires non-empty valid `context_group` + - `context_group` is normalized (trim + lowercase + whitespace collapse to `-`) + - API route rejects `context_group` when mode is not `shared` + - registry normalization drops malformed persisted `context_group` values + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 60c442e4..85d1a7ed 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-02-04 +Last Updated: 2026-02-24 CLI commands for managing CCS dashboard authentication. @@ -10,6 +10,35 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it. +## Account Context Modes (Related Feature) + +Dashboard auth and account context metadata are separate: + +- `dashboard_auth`: protects dashboard access with username/password +- `accounts..context_mode/context_group`: controls isolated vs shared account context + +Account context is isolation-first: + +| Mode | Default | Requirement | +|------|---------|-------------| +| `isolated` | Yes | No `context_group` required | +| `shared` | No (opt-in) | Valid non-empty `context_group` | + +`context_group` normalization and validation: + +- trim + lowercase + collapse internal whitespace to `-` +- allowed characters: lowercase letters, numbers, `_`, `-` +- must start with a letter +- max length: 64 +- shared mode requires non-empty value after normalization + +`PUT /api/config` behavior for account context: + +- rejects invalid unified payloads +- rejects explicit `context_mode: shared` with invalid/empty `context_group` +- normalizes valid shared `context_group` before save +- rejects `context_group` when mode is not `shared` + ## Commands ### `ccs config auth setup` @@ -162,6 +191,10 @@ export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...' Check `session_timeout_hours` in config. Default is 24 hours. +### "Invalid ... context_group ..." + +This error comes from `PUT /api/config` when an account explicitly sets shared mode with an invalid group. Use a canonical group value (for example: `team-alpha`). + ## See Also - [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index f97a89b5..c30a0f7c 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -38,7 +38,7 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; * Normalize context group names so paths and config stay consistent. */ export function normalizeContextGroupName(value: string): string { - return value.trim().toLowerCase(); + return value.trim().toLowerCase().replace(/\s+/g, '-'); } /** diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index e94c47f8..2ed849ee 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -14,6 +14,7 @@ import ProfileRegistry from './profile-registry'; import { InstanceManager } from '../management/instance-manager'; import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui'; +import { MAX_CONTEXT_GROUP_LENGTH } from './account-context'; import packageJson from '../../package.json'; // Import command handlers from modular structure @@ -127,6 +128,10 @@ class AuthCommands { console.log( ` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.` ); + console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); + console.log( + ` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.` + ); console.log(''); } diff --git a/src/auth/commands/create-command-env.ts b/src/auth/commands/create-command-env.ts new file mode 100644 index 00000000..630e9a0e --- /dev/null +++ b/src/auth/commands/create-command-env.ts @@ -0,0 +1,57 @@ +const AMBIENT_PROVIDER_PREFIXES = [ + 'ANTHROPIC_', + 'OPENAI_', + 'GOOGLE_', + 'GEMINI_', + 'MINIMAX_', + 'QWEN_', + 'DEEPSEEK_', + 'KIMI_', + 'AZURE_', + 'OLLAMA_', + 'OPENROUTER_', + 'XAI_', + 'MISTRAL_', + 'COHERE_', + 'PERPLEXITY_', + 'TOGETHER_', + 'FIREWORKS_', +]; +const AMBIENT_PROVIDER_EXACT_KEYS = new Set([ + 'OPENROUTER_API_KEY', + 'OPENROUTER_KEY', + 'XAI_API_KEY', + 'MISTRAL_API_KEY', + 'COHERE_API_KEY', +]); +const AMBIENT_PROVIDER_SUFFIXES = [ + '_API_KEY', + '_AUTH_TOKEN', + '_ACCESS_TOKEN', + '_SECRET_KEY', + '_API_TOKEN', + '_BEARER_TOKEN', + '_SESSION_TOKEN', +]; + +export function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sanitized: NodeJS.ProcessEnv = { ...env }; + + for (const envKey of Object.keys(sanitized)) { + const normalizedKey = envKey.toUpperCase(); + + if (normalizedKey === 'CLAUDE_CONFIG_DIR') { + continue; + } + + if ( + AMBIENT_PROVIDER_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix)) || + AMBIENT_PROVIDER_EXACT_KEYS.has(normalizedKey) || + AMBIENT_PROVIDER_SUFFIXES.some((suffix) => normalizedKey.endsWith(suffix)) + ) { + delete sanitized[envKey]; + } + } + + return sanitized; +} diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 614a0ec8..8c169c1d 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -20,52 +20,12 @@ import { import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; +import { stripAmbientProviderCredentials } from './create-command-env'; function sanitizeProfileNameForInstance(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); } -const AMBIENT_PROVIDER_PREFIXES = [ - 'ANTHROPIC_', - 'OPENAI_', - 'GOOGLE_', - 'GEMINI_', - 'MINIMAX_', - 'QWEN_', - 'DEEPSEEK_', - 'KIMI_', - 'AZURE_', - 'OLLAMA_', -]; -const AMBIENT_PROVIDER_EXACT_KEYS = new Set([ - 'OPENROUTER_API_KEY', - 'OPENROUTER_KEY', - 'XAI_API_KEY', - 'MISTRAL_API_KEY', - 'COHERE_API_KEY', -]); -const AMBIENT_PROVIDER_SUFFIXES = ['_API_KEY', '_AUTH_TOKEN', '_ACCESS_TOKEN', '_SECRET_KEY']; - -function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const sanitized: NodeJS.ProcessEnv = { ...env }; - - for (const envKey of Object.keys(sanitized)) { - if (envKey === 'CLAUDE_CONFIG_DIR') { - continue; - } - - if ( - AMBIENT_PROVIDER_PREFIXES.some((prefix) => envKey.startsWith(prefix)) || - AMBIENT_PROVIDER_EXACT_KEYS.has(envKey) || - AMBIENT_PROVIDER_SUFFIXES.some((suffix) => envKey.endsWith(suffix)) - ) { - delete sanitized[envKey]; - } - } - - return sanitized; -} - /** * Handle the create command */ @@ -74,9 +34,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args); if (unknownFlags && unknownFlags.length > 0) { - const unknownList = unknownFlags.join(', '); + const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(''); + console.log( + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ]', 'command')}` + ); + console.log(`Help: ${color('ccs auth --help', 'command')}`); + console.log(''); exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); } @@ -135,15 +100,17 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const contextPolicy = resolvedContext.policy; const contextMetadata = policyToAccountContextMetadata(contextPolicy); const useUnifiedConfig = isUnifiedMode(); - const createdProfile = useUnifiedConfig ? !existsUnified : !existsLegacy; - const previousLegacyProfile: ProfileMetadata | undefined = - !useUnifiedConfig && existsLegacy ? ctx.registry.getProfile(profileName) : undefined; - const previousUnifiedProfile = - useUnifiedConfig && existsUnified - ? ctx.registry.getAllAccountsUnified()[profileName] - : undefined; + const profileExistedBeforeCreate = existsLegacy || existsUnified; + const createdUnifiedProfile = useUnifiedConfig && !existsUnified; + const createdLegacyProfile = !useUnifiedConfig && !existsLegacy; + const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy + ? ctx.registry.getProfile(profileName) + : undefined; + const previousUnifiedProfile = existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; const previousContextPolicy = - !createdProfile && (previousUnifiedProfile || previousLegacyProfile) + profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) : undefined; @@ -160,22 +127,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise const rollbackMetadata = (): void => { try { if (useUnifiedConfig) { - if (createdProfile) { + if (createdUnifiedProfile) { if (ctx.registry.hasAccountUnified(profileName)) { ctx.registry.removeAccountUnified(profileName); } } else if (previousUnifiedProfile) { ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile); } - return; - } - - if (createdProfile) { - if (ctx.registry.hasProfile(profileName)) { - ctx.registry.deleteProfile(profileName); + } else { + if (createdLegacyProfile) { + if (ctx.registry.hasProfile(profileName)) { + ctx.registry.deleteProfile(profileName); + } + } else if (previousLegacyProfile) { + ctx.registry.updateProfile(profileName, previousLegacyProfile); } - } else if (previousLegacyProfile) { - ctx.registry.updateProfile(profileName, previousLegacyProfile); } } catch { // Best-effort rollback to avoid leaving stale accounts after failed login. @@ -190,7 +156,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise rollbackMetadata(); - if (createdProfile) { + if (!profileExistedBeforeCreate) { try { ctx.instanceMgr.deleteInstance(profileName); } catch { diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 5870db61..f70f7e79 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -8,6 +8,7 @@ import { } from '../config/unified-config-loader'; import type { AccountConfig } from '../config/unified-config-types'; import { getCcsDir } from '../utils/config-manager'; +import { isValidContextGroupName, normalizeContextGroupName } from './account-context'; /** * Profile Registry (Simplified) @@ -51,13 +52,31 @@ export class ProfileRegistry { this.profilesPath = path.join(getCcsDir(), 'profiles.json'); } + private normalizeContextGroupValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = normalizeContextGroupName(value); + if (normalized.length === 0 || !isValidContextGroupName(normalized)) { + return undefined; + } + + return normalized; + } + private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata { const normalized: ProfileMetadata = { ...metadata }; if (normalized.context_mode !== 'shared') { delete normalized.context_group; - } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { - delete normalized.context_group; + } else { + const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group); + if (normalizedGroup) { + normalized.context_group = normalizedGroup; + } else { + delete normalized.context_group; + } } return normalized; @@ -68,8 +87,13 @@ export class ProfileRegistry { if (normalized.context_mode !== 'shared') { delete normalized.context_group; - } else if (!normalized.context_group || normalized.context_group.trim().length === 0) { - delete normalized.context_group; + } else { + const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group); + if (normalizedGroup) { + normalized.context_group = normalizedGroup; + } else { + delete normalized.context_group; + } } return normalized; diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index e39acbb1..c0142f06 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -8,8 +8,8 @@ import * as fs from 'fs'; import * as path from 'path'; -import { createHash } from 'crypto'; import SharedManager from './shared-manager'; +import ProfileContextSyncLock from './profile-context-sync-lock'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -18,13 +18,13 @@ import { getCcsDir } from '../utils/config-manager'; */ class InstanceManager { private readonly instancesDir: string; - private readonly locksDir: string; private readonly sharedManager: SharedManager; + private readonly contextSyncLock: ProfileContextSyncLock; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); - this.locksDir = path.join(this.instancesDir, '.locks'); this.sharedManager = new SharedManager(); + this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir); } /** @@ -37,7 +37,7 @@ class InstanceManager { const instancePath = this.getInstancePath(profileName); // Serialize context sync operations per profile across processes. - await this.withContextSyncLock(profileName, async () => { + await this.contextSyncLock.withLock(profileName, async () => { // Lazy initialization if (!fs.existsSync(instancePath)) { this.initializeInstance(profileName, instancePath); @@ -201,100 +201,6 @@ class InstanceManager { // Replace unsafe characters with dash return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); } - - private getContextSyncLockPath(profileName: string): string { - const safeName = this.sanitizeName(profileName); - // Keep lock filenames deterministic while preventing normalized-name collisions. - const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8); - return path.join(this.locksDir, `${safeName}-${profileHash}.lock`); - } - - private isProcessAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) { - return false; - } - - try { - process.kill(pid, 0); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EPERM') { - return true; - } - return false; - } - } - - private tryRemoveDeadOwnerLock(lockPath: string): boolean { - try { - const lockContent = fs.readFileSync(lockPath, 'utf8').trim(); - const lockPid = Number.parseInt(lockContent, 10); - if (!this.isProcessAlive(lockPid)) { - fs.unlinkSync(lockPath); - return true; - } - } catch { - // Best-effort stale lock cleanup. - } - return false; - } - - private async withContextSyncLock( - profileName: string, - callback: () => Promise - ): Promise { - const lockPath = this.getContextSyncLockPath(profileName); - const retryDelayMs = 50; - const staleLockMs = 30000; - const timeoutMs = staleLockMs + 5000; - const start = Date.now(); - - fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); - - while (true) { - try { - const fd = fs.openSync(lockPath, 'wx', 0o600); - fs.writeFileSync(fd, `${process.pid}`); - fs.closeSync(fd); - break; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== 'EEXIST') { - throw error; - } - - try { - const lockStats = fs.statSync(lockPath); - if (Date.now() - lockStats.mtimeMs > staleLockMs) { - fs.unlinkSync(lockPath); - continue; - } - - if (this.tryRemoveDeadOwnerLock(lockPath)) { - continue; - } - } catch { - // Best-effort stale lock cleanup. - } - - if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for profile context lock: ${profileName}`); - } - - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); - } - } - - try { - return await callback(); - } finally { - try { - fs.unlinkSync(lockPath); - } catch { - // Best-effort cleanup. - } - } - } } export { InstanceManager }; diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts new file mode 100644 index 00000000..c8158bc3 --- /dev/null +++ b/src/management/profile-context-sync-lock.ts @@ -0,0 +1,177 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { createHash } from 'crypto'; + +interface ContextSyncLockPayload { + version: 1; + pid: number; + nonce: string; + acquiredAtMs: number; +} + +interface ContextSyncLockSnapshot { + raw: string; + owner: { pid: number; nonce?: string } | null; +} + +class ProfileContextSyncLock { + private readonly locksDir: string; + + constructor(instancesDir: string) { + this.locksDir = path.join(instancesDir, '.locks'); + } + + private sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + } + + private getLockPath(profileName: string): string { + const safeName = this.sanitizeName(profileName); + const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8); + return path.join(this.locksDir, `${safeName}-${profileHash}.lock`); + } + + private isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + return true; + } + return false; + } + } + + private parseContextSyncLock(raw: string): { pid: number; nonce?: string } | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return null; + } + + try { + const parsed = JSON.parse(trimmed) as Partial; + if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0) { + const nonce = + typeof parsed.nonce === 'string' && parsed.nonce.length > 0 ? parsed.nonce : undefined; + return { pid: parsed.pid, nonce }; + } + } catch { + const legacyPid = Number.parseInt(trimmed, 10); + if (Number.isInteger(legacyPid) && legacyPid > 0) { + return { pid: legacyPid }; + } + } + + return null; + } + + private readContextSyncLockSnapshot(lockPath: string): ContextSyncLockSnapshot | null { + try { + const raw = fs.readFileSync(lockPath, 'utf8'); + return { + raw, + owner: this.parseContextSyncLock(raw), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + return null; + } + } + + private tryRemoveLockIfUnchanged(lockPath: string, expectedRaw: string): boolean { + try { + const currentRaw = fs.readFileSync(lockPath, 'utf8'); + if (currentRaw !== expectedRaw) { + return false; + } + fs.unlinkSync(lockPath); + return true; + } catch { + return false; + } + } + + private tryRemoveDeadOwnerLock(lockPath: string, snapshot: ContextSyncLockSnapshot): boolean { + if (!snapshot.owner || this.isProcessAlive(snapshot.owner.pid)) { + return false; + } + + return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw); + } + + async withLock(profileName: string, callback: () => Promise): Promise { + const lockPath = this.getLockPath(profileName); + const retryDelayMs = 50; + const staleLockMs = 30000; + const timeoutMs = staleLockMs + 5000; + const start = Date.now(); + const ownerPayload: ContextSyncLockPayload = { + version: 1, + pid: process.pid, + nonce: createHash('sha1') + .update(`${process.pid}:${Date.now()}:${Math.random()}`) + .digest('hex') + .slice(0, 16), + acquiredAtMs: Date.now(), + }; + const ownerPayloadRaw = JSON.stringify(ownerPayload); + + fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(fd, ownerPayloadRaw, 'utf8'); + fs.closeSync(fd); + break; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw error; + } + + const lockSnapshot = this.readContextSyncLockSnapshot(lockPath); + if (lockSnapshot) { + if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) { + continue; + } + + // For malformed lock payloads, fall back to age-based stale cleanup. + if (!lockSnapshot.owner) { + try { + const lockStats = fs.statSync(lockPath); + if (Date.now() - lockStats.mtimeMs > staleLockMs) { + if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) { + continue; + } + } + } catch { + // Best-effort stale lock cleanup. + } + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for profile context lock: ${profileName}`); + } + + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + try { + return await callback(); + } finally { + this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); + } + } +} + +export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index e2ae5ad8..4fc0e394 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -63,8 +63,14 @@ class SharedManager { const resolvedTarget = path.resolve(path.dirname(target), targetLink); // Check if target points back to our shared dir or link path - const sharedDir = path.join(getCcsDir(), 'shared'); - if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) { + const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared')); + const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget); + const canonicalLinkPath = this.resolveCanonicalPath(linkPath); + + if ( + this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir) || + canonicalResolvedTarget === canonicalLinkPath + ) { console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); return true; } @@ -692,17 +698,17 @@ class SharedManager { * Guard project merge operations to known CCS-managed roots only. */ private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean { - const resolvedSource = path.resolve(sourcePath); - const sharedContextRoot = path.resolve(path.join(this.sharedDir, 'context-groups')); - const instanceProjectsRoot = path.resolve( + const resolvedSource = this.resolveCanonicalPath(sourcePath); + const sharedContextRoot = this.resolveCanonicalPath( + path.join(this.sharedDir, 'context-groups') + ); + const instanceProjectsRoot = this.resolveCanonicalPath( path.join(this.instancesDir, instanceName, 'projects') ); - const isWithin = (child: string, root: string): boolean => - child === root || child.startsWith(`${root}${path.sep}`); - return ( - isWithin(resolvedSource, sharedContextRoot) || isWithin(resolvedSource, instanceProjectsRoot) + this.isPathWithinDirectory(resolvedSource, sharedContextRoot) || + this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot) ); } @@ -736,7 +742,7 @@ class SharedManager { projectsPath: string, instanceName: string ): Promise { - const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory')); + const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory')); let projectEntries: fs.Dirent[] = []; try { @@ -763,15 +769,20 @@ class SharedManager { continue; } - if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) { + const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget); + if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) { continue; } await fs.promises.unlink(memoryPath); await this.ensureDirectory(memoryPath); - if (await this.pathExists(memoryTarget)) { - await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName); + if (await this.pathExists(canonicalMemoryTarget)) { + await this.mergeDirectoryWithConflictCopies( + canonicalMemoryTarget, + memoryPath, + instanceName + ); } } } @@ -879,6 +890,27 @@ class SharedManager { return candidate; } + private resolveCanonicalPath(targetPath: string): string { + try { + return fs.realpathSync.native(targetPath); + } catch { + return path.resolve(targetPath); + } + } + + private isPathWithinDirectory(candidatePath: string, rootPath: string): boolean { + const normalizeForCompare = (inputPath: string): string => { + const resolved = path.resolve(inputPath); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + + const normalizedCandidate = normalizeForCompare(candidatePath); + const normalizedRoot = normalizeForCompare(rootPath); + const relative = path.relative(normalizedRoot, normalizedCandidate); + + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } + private async pathExists(targetPath: string): Promise { try { await fs.promises.access(targetPath); diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 90ace229..b6ebc359 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -206,25 +206,24 @@ router.delete('/:name', (req: Request, res: Response): void => { return; } - // Delete from appropriate config (unified and/or legacy) - let deleted = false; - if (isUnifiedMode() && registry.hasAccountUnified(name)) { - registry.removeAccountUnified(name); - deleted = true; - } - if (registry.hasProfile(name)) { - registry.deleteProfile(name); - deleted = true; - } + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); - if (!deleted) { + if (!existsUnified && !existsLegacy) { res.status(404).json({ error: `Account not found: ${name}` }); return; } - // Keep API delete behavior aligned with CLI remove command. + // Match CLI remove ordering: delete instance first, metadata second. instanceMgr.deleteInstance(name); + if (existsUnified) { + registry.removeAccountUnified(name); + } + if (existsLegacy) { + registry.deleteProfile(name); + } + res.json({ success: true, deleted: name }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 537f7348..31a25cc7 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -22,7 +22,7 @@ import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/a const router = Router(); -function validateAccountContextMetadata(config: unknown): string | null { +function validateAndNormalizeAccountContextMetadata(config: unknown): string | null { if (typeof config !== 'object' || config === null) { return 'Invalid config payload'; } @@ -63,6 +63,15 @@ function validateAccountContextMetadata(config: unknown): string | null { if (!isValidContextGroupName(normalizedGroup)) { return `Invalid config.accounts.${accountName}.context_group`; } + account.context_group = normalizedGroup; + } + + if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) { + return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`; + } + + if (mode === 'isolated' && group !== undefined) { + delete account.context_group; } } @@ -130,7 +139,7 @@ router.put('/', (req: Request, res: Response): void => { return; } - const accountContextError = validateAccountContextMetadata(config); + const accountContextError = validateAndNormalizeAccountContextMetadata(config); if (accountContextError) { res.status(400).json({ error: accountContextError }); return; @@ -150,6 +159,15 @@ router.put('/', (req: Request, res: Response): void => { router.post('/migrate', async (req: Request, res: Response): Promise => { try { const dryRun = req.query.dryRun === 'true'; + if (!needsMigration()) { + res.json({ + success: true, + migratedFiles: [], + warnings: [], + alreadyMigrated: true, + }); + return; + } const result = await migrate(dryRun); res.json(result); } catch (error) { diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index 7e282a58..2c8f658f 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -40,4 +40,15 @@ describe('account context helpers', () => { expect(resolved.mode).toBe('shared'); expect(resolved.group).toBe('sprint-a'); }); + + it('normalizes whitespace in explicit shared context group names', () => { + const result = resolveCreateAccountContext({ + shareContext: false, + contextGroup: ' Team Alpha ', + }); + + expect(result.error).toBeUndefined(); + expect(result.policy.mode).toBe('shared'); + expect(result.policy.group).toBe('team-alpha'); + }); }); diff --git a/tests/unit/auth/profile-registry-context-normalization.test.ts b/tests/unit/auth/profile-registry-context-normalization.test.ts new file mode 100644 index 00000000..95882b35 --- /dev/null +++ b/tests/unit/auth/profile-registry-context-normalization.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../../src/auth/profile-registry'; + +describe('profile-registry context normalization', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-registry-context-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('drops non-string legacy context_group values without throwing', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + version: '2.0.0', + default: null, + profiles: { + work: { + type: 'account', + created: '2026-02-24T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: { invalid: true }, + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const profile = registry.getProfile('work'); + + expect(profile.context_mode).toBe('shared'); + expect(profile.context_group).toBeUndefined(); + }); + + it('drops non-string unified context_group values without throwing', () => { + process.env.CCS_UNIFIED_CONFIG = '1'; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-24T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: 123', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const accounts = registry.getAllAccountsUnified(); + + expect(accounts.work.context_mode).toBe('shared'); + expect(accounts.work.context_group).toBeUndefined(); + }); +}); diff --git a/tests/unit/shared-context-policy.test.ts b/tests/unit/shared-context-policy.test.ts index dffbd37f..b882b5d5 100644 --- a/tests/unit/shared-context-policy.test.ts +++ b/tests/unit/shared-context-policy.test.ts @@ -13,6 +13,12 @@ function getTestCcsDir(): string { return path.join(path.resolve(process.env.CCS_HOME), '.ccs'); } +function createDirectorySymlink(targetPath: string, linkPath: string): void { + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + fs.symlinkSync(linkTarget, linkPath, symlinkType); +} + describe('SharedManager context policy', () => { let tempRoot = ''; let originalHome: string | undefined; @@ -134,4 +140,42 @@ describe('SharedManager context policy', () => { expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true); }); + + it('skips merge when projects symlink target is outside canonical CCS roots', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectsPath = path.join(instancePath, 'projects'); + const unsafeProjectsPath = path.join(ccsDir, 'shared', 'context-groups-evil', 'projects'); + const unsafeFile = path.join(unsafeProjectsPath, '-tmp-project', 'notes.md'); + + fs.mkdirSync(path.dirname(unsafeFile), { recursive: true }); + fs.writeFileSync(unsafeFile, 'unsafe source', 'utf8'); + fs.mkdirSync(instancePath, { recursive: true }); + createDirectorySymlink(unsafeProjectsPath, projectsPath); + + const manager = new SharedManager(); + await manager.syncProjectContext(instancePath, { mode: 'isolated' }); + + expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(projectsPath, '-tmp-project', 'notes.md'))).toBe(false); + }); + + it('does not detach project memory symlink from lookalike shared path prefixes', async () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectPath = path.join(instancePath, 'projects', '-tmp-project'); + const memoryPath = path.join(projectPath, 'memory'); + const unsafeMemoryTarget = path.join(ccsDir, 'shared', 'memory-evil', '-tmp-project'); + const unsafeMemoryFile = path.join(unsafeMemoryTarget, 'MEMORY.md'); + + fs.mkdirSync(path.dirname(unsafeMemoryFile), { recursive: true }); + fs.writeFileSync(unsafeMemoryFile, 'unsafe memory', 'utf8'); + fs.mkdirSync(projectPath, { recursive: true }); + createDirectorySymlink(unsafeMemoryTarget, memoryPath); + + const manager = new SharedManager(); + await manager.syncProjectContext(instancePath, { mode: 'isolated' }); + + expect(fs.lstatSync(memoryPath).isSymbolicLink()).toBe(true); + }); }); diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 0563c129..c3b27999 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -5,6 +5,8 @@ import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; import accountRoutes from '../../../src/web-server/routes/account-routes'; +import ProfileRegistry from '../../../src/auth/profile-registry'; +import { InstanceManager } from '../../../src/management/instance-manager'; async function getJson(baseUrl: string, routePath: string): Promise { const response = await fetch(`${baseUrl}${routePath}`); @@ -12,6 +14,10 @@ async function getJson(baseUrl: string, routePath: string): Promise { return (await response.json()) as T; } +async function deletePath(baseUrl: string, routePath: string): Promise { + return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' }); +} + describe('web-server account-routes context normalization', () => { let server: Server; let baseUrl = ''; @@ -130,4 +136,41 @@ describe('web-server account-routes context normalization', () => { expect(work?.context_mode).toBe('shared'); expect(work?.context_group).toBe('default'); }); + + it('does not delete metadata when instance deletion fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + const registry = new ProfileRegistry(); + + const originalDeleteInstance = InstanceManager.prototype.deleteInstance; + InstanceManager.prototype.deleteInstance = () => { + throw new Error('simulated instance delete failure'); + }; + + try { + const response = await deletePath(baseUrl, '/api/accounts/work'); + expect(response.status).toBe(500); + expect(registry.hasAccountUnified('work')).toBe(true); + } finally { + InstanceManager.prototype.deleteInstance = originalDeleteInstance; + } + }); }); diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index 850f654f..a1bcb728 100644 --- a/tests/unit/web-server/config-routes-account-context.test.ts +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import type { Server } from 'http'; import configRoutes from '../../../src/web-server/routes/config-routes'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; +import { loadUnifiedConfig } from '../../../src/config/unified-config-loader'; async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { return fetch(`${baseUrl}${routePath}`, { @@ -17,6 +18,16 @@ async function putJson(baseUrl: string, routePath: string, body: unknown): Promi }); } +async function postJson(baseUrl: string, routePath: string, body?: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + describe('web-server config-routes account context validation', () => { let server: Server; let baseUrl = ''; @@ -124,6 +135,26 @@ describe('web-server config-routes account context validation', () => { expect(payload.error).toContain('context_group'); }); + it('rejects whitespace-only shared context_group values', async () => { + const response = await putJson(baseUrl, '/api/config', { + version: 8, + accounts: { + work: { + created: '2026-01-01T00:00:00.000Z', + last_used: null, + context_mode: 'shared', + context_group: ' ', + }, + }, + profiles: {}, + cliproxy: { oauth_accounts: {}, providers: [], variants: {} }, + }); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('requires a non-empty value'); + }); + it('accepts valid shared context metadata', async () => { const config = createEmptyUnifiedConfig(); config.accounts.work = { @@ -137,5 +168,24 @@ describe('web-server config-routes account context validation', () => { expect(response.status).toBe(200); const payload = (await response.json()) as { success: boolean }; expect(payload.success).toBe(true); + + const savedConfig = loadUnifiedConfig(); + expect(savedConfig?.accounts.work.context_group).toBe('sprint-a'); + }); + + it('returns alreadyMigrated when migration is not needed', async () => { + const response = await postJson(baseUrl, '/api/config/migrate'); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + success: boolean; + migratedFiles: string[]; + warnings: string[]; + alreadyMigrated?: boolean; + }; + expect(payload.success).toBe(true); + expect(payload.migratedFiles).toEqual([]); + expect(payload.warnings).toEqual([]); + expect(payload.alreadyMigrated).toBe(true); }); }); From 089aa081361f8375a951579ee4f80230efcac904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= Date: Wed, 25 Feb 2026 14:24:20 +0800 Subject: [PATCH 14/94] feat(pricing): add MiniMax M2.5/M2.5-lightning and Qwen3 series (max/plus/flash/coder) model pricing - Add MiniMax M2.5 and M2.5-lightning pricing - Fix MiniMax M2.1-lightning input price (0.3 -> 0.6) - Add Qwen3 series: qwen3-max, qwen3.5-plus, qwen3.5-flash, qwen3-coder-plus, qwen3-coder-flash - Include date-stamped and preview variants for qwen3-max --- src/web-server/model-pricing.ts | 59 ++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index dfc654a6..59054efc 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -559,6 +559,18 @@ const PRICING_REGISTRY: Record = { // --------------------------------------------------------------------------- // MiniMax Models - Source: https://platform.minimax.io/docs/pricing/pay-as-you-go // --------------------------------------------------------------------------- + 'MiniMax-M2.5': { + inputPerMillion: 0.3, + outputPerMillion: 1.2, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, + 'MiniMax-M2.5-lightning': { + inputPerMillion: 0.6, + outputPerMillion: 2.4, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, 'MiniMax-M2.1': { inputPerMillion: 0.3, outputPerMillion: 1.2, @@ -566,7 +578,7 @@ const PRICING_REGISTRY: Record = { cacheReadPerMillion: 0.03, }, 'MiniMax-M2.1-lightning': { - inputPerMillion: 0.3, + inputPerMillion: 0.6, outputPerMillion: 2.4, cacheCreationPerMillion: 0.375, cacheReadPerMillion: 0.03, @@ -577,6 +589,51 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 0.375, cacheReadPerMillion: 0.03, }, + // --------------------------------------------------------------------------- + // Qwen Models - Source: https://www.alibabacloud.com/help/zh/model-studio/model-pricing + // --------------------------------------------------------------------------- + 'qwen3-max': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3-max-2026-01-23': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3-max-preview': { + inputPerMillion: 1.2, + outputPerMillion: 6, + cacheCreationPerMillion: 1.2, + cacheReadPerMillion: 0.24, + }, + 'qwen3.5-plus': { + inputPerMillion: 0.4, + outputPerMillion: 2.4, + cacheCreationPerMillion: 0.4, + cacheReadPerMillion: 0.08, + }, + 'qwen3.5-flash': { + inputPerMillion: 0.1, + outputPerMillion: 0.4, + cacheCreationPerMillion: 0.1, + cacheReadPerMillion: 0.02, + }, + 'qwen3-coder-plus': { + inputPerMillion: 1, + outputPerMillion: 5, + cacheCreationPerMillion: 1, + cacheReadPerMillion: 0.2, + }, + 'qwen3-coder-flash': { + inputPerMillion: 0.3, + outputPerMillion: 1.5, + cacheCreationPerMillion: 0.3, + cacheReadPerMillion: 0.06, + }, // --------------------------------------------------------------------------- // DeepSeek Models - Source: better-ccusage From 461236e5decc88e3488781cd94528d2ef99a3e9d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:31 +0700 Subject: [PATCH 15/94] feat(api): persist profile target metadata - add target fields to API profile and CLIProxy variant service types - persist target in unified config and legacy profile_targets map - expose target update API via updateApiProfileTarget --- src/api/services/index.ts | 3 +- src/api/services/profile-reader.ts | 9 ++- src/api/services/profile-types.ts | 11 ++++ src/api/services/profile-writer.ts | 90 +++++++++++++++++++++++++++--- 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/api/services/index.ts b/src/api/services/index.ts index bdb07b20..57cceba4 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -15,6 +15,7 @@ export { type ApiListResult, type CreateApiProfileResult, type RemoveApiProfileResult, + type UpdateApiProfileTargetResult, } from './profile-types'; // Profile read operations @@ -27,7 +28,7 @@ export { } from './profile-reader'; // Profile write operations -export { createApiProfile, removeApiProfile } from './profile-writer'; +export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index f31d4986..028931c7 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; /** @@ -68,6 +69,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', + target: profile.target || 'claude', }); } // CLIProxy variants @@ -80,10 +82,13 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', + target: variant?.target || 'claude', }); } } else { const config = loadConfigSafe(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { @@ -94,16 +99,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', + target: legacyTargetMap?.[name] || 'claude', }); } // CLIProxy variants if (config.cliproxy) { for (const [name, v] of Object.entries(config.cliproxy)) { - const variant = v as { provider: string; settings: string }; + const variant = v as { provider: string; settings: string; target?: TargetType }; variants.push({ name, provider: variant.provider, settings: variant.settings, + target: variant.target || 'claude', }); } } diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 75a0b76b..62da4667 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -4,6 +4,8 @@ * Shared type definitions for API profile services. */ +import type { TargetType } from '../../targets/target-adapter'; + /** Model mapping for API profiles */ export interface ModelMapping { default: string; @@ -18,6 +20,7 @@ export interface ApiProfileInfo { settingsPath: string; isConfigured: boolean; configSource: 'unified' | 'legacy'; + target: TargetType; } /** CLIProxy variant info */ @@ -25,6 +28,7 @@ export interface CliproxyVariantInfo { name: string; provider: string; settings: string; + target: TargetType; } /** Result from list operation */ @@ -45,3 +49,10 @@ export interface RemoveApiProfileResult { success: boolean; error?: string; } + +/** Result from updating API profile target */ +export interface UpdateApiProfileTargetResult { + success: boolean; + target?: TargetType; + error?: string; +} diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index ebac9446..fa8ff9c4 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -12,7 +12,13 @@ import { isUnifiedMode, } from '../../config/unified-config-loader'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; -import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types'; +import type { TargetType } from '../../targets/target-adapter'; +import type { + ModelMapping, + CreateApiProfileResult, + RemoveApiProfileResult, + UpdateApiProfileTargetResult, +} from './profile-types'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -51,11 +57,15 @@ function createSettingsFile( } /** Update config.json with new API profile (legacy format) */ -function updateLegacyConfig(name: string): void { +function updateLegacyConfig(name: string, target: TargetType = 'claude'): void { const configPath = getConfigPath(); const ccsDir = getCcsDir(); - let config: { profiles: Record; cliproxy?: Record }; + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { @@ -64,6 +74,12 @@ function updateLegacyConfig(name: string): void { const relativePath = `~/.ccs/${name}.settings.json`; config.profiles[name] = relativePath; + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } if (!fs.existsSync(ccsDir)) { fs.mkdirSync(ccsDir, { recursive: true }); @@ -80,7 +96,8 @@ function createApiProfileUnified( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude' ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; @@ -112,6 +129,7 @@ function createApiProfileUnified( config.profiles[name] = { type: 'api', settings: `~/.ccs/${settingsFile}`, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } @@ -121,16 +139,17 @@ export function createApiProfile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + target: TargetType = 'claude' ): CreateApiProfileResult { try { const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models); + createApiProfileUnified(name, baseUrl, apiKey, models, target); } else { createSettingsFile(name, baseUrl, apiKey, models); - updateLegacyConfig(name); + updateLegacyConfig(name, target); } return { success: true, settingsFile }; @@ -143,6 +162,63 @@ export function createApiProfile( } } +/** + * Update API profile target (claude/droid). + * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. + */ +export function updateApiProfileTarget( + name: string, + target: TargetType +): UpdateApiProfileTargetResult { + try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + if (target === 'claude') { + delete config.profiles[name].target; + } else { + config.profiles[name].target = target; + } + saveUnifiedConfig(config); + return { success: true, target }; + } + + const configPath = getConfigPath(); + let config: { + profiles: Record; + cliproxy?: Record; + profile_targets?: Record; + }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + if (!config.profiles[name]) { + return { success: false, error: `API profile not found: ${name}` }; + } + + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + return { success: true, target }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + /** Remove API profile from unified config */ function removeApiProfileUnified(name: string): void { const config = loadOrCreateUnifiedConfig(); From e8330546429803a1a94cc508e3dc6a1572ecef9c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:43 +0700 Subject: [PATCH 16/94] feat(api): support profile target in routes - validate target payloads for profile create/update - return target in profile list and create responses - allow updating target independently from settings values --- src/web-server/routes/profile-routes.ts | 98 ++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 37a895dc..70aad36b 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -7,12 +7,34 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; -import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer'; +import { + createApiProfile, + removeApiProfile, + updateApiProfileTarget, +} from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; +import type { TargetType } from '../../targets/target-adapter'; import { updateSettingsFile } from './route-helpers'; const router = Router(); +function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + // ==================== Profile CRUD ==================== /** @@ -26,6 +48,7 @@ router.get('/', (_req: Request, res: Response): void => { name: p.name, settingsPath: p.settingsPath, configured: p.isConfigured, + target: p.target, })); res.json({ profiles }); } catch (error) { @@ -37,7 +60,13 @@ router.get('/', (_req: Request, res: Response): void => { * POST /api/profiles - Create new profile */ router.post('/', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } if (!name || !baseUrl || !apiKey) { res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); @@ -60,19 +89,29 @@ router.post('/', (req: Request, res: Response): void => { } // Create profile using unified-config-aware service - const result = createApiProfile(name, baseUrl, apiKey, { - default: model || '', - opus: opusModel || model || '', - sonnet: sonnetModel || model || '', - haiku: haikuModel || model || '', - }); + const result = createApiProfile( + name, + baseUrl, + apiKey, + { + default: model || '', + opus: opusModel || model || '', + sonnet: sonnetModel || model || '', + haiku: haikuModel || model || '', + }, + parsedTarget || 'claude' + ); if (!result.success) { res.status(500).json({ error: result.error || 'Failed to create profile' }); return; } - res.status(201).json({ name, settingsPath: result.settingsFile }); + res.status(201).json({ + name, + settingsPath: result.settingsFile, + target: parsedTarget || 'claude', + }); }); /** @@ -80,7 +119,13 @@ router.post('/', (req: Request, res: Response): void => { */ router.put('/:name', (req: Request, res: Response): void => { const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + + const parsedTarget = parseTarget(target); + if (target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } // Check if profile exists (uses unified config when available) if (!apiProfileExists(name)) { @@ -99,8 +144,37 @@ router.put('/:name', (req: Request, res: Response): void => { } try { - updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); - res.json({ name, updated: true }); + const hasSettingsUpdates = + baseUrl !== undefined || + apiKey !== undefined || + model !== undefined || + opusModel !== undefined || + sonnetModel !== undefined || + haikuModel !== undefined; + const hasTargetUpdate = target !== undefined; + + if (!hasSettingsUpdates && !hasTargetUpdate) { + res.status(400).json({ error: 'No updates provided' }); + return; + } + + if (hasSettingsUpdates) { + updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); + } + + if (hasTargetUpdate && parsedTarget) { + const targetUpdate = updateApiProfileTarget(name, parsedTarget); + if (!targetUpdate.success) { + res.status(500).json({ error: targetUpdate.error || 'Failed to update target' }); + return; + } + } + + res.json({ + name, + updated: true, + ...(hasTargetUpdate && parsedTarget && { target: parsedTarget }), + }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } From 3dacb39deb5cf88978c7d98e3cd95c5a12dca455 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:51:54 +0700 Subject: [PATCH 17/94] feat(api): add --target to api command - parse and validate --target claude|droid on api create flow - show target in API and CLIProxy list tables and usage output - add parser unit tests for target success and failure cases --- src/commands/api-command.ts | 79 +++++++++++++++++--- tests/unit/commands/api-command-args.test.ts | 15 ++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 495617d0..cdbb2f43 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -43,6 +43,7 @@ import { type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; +import type { TargetType } from '../targets/target-adapter'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ApiCommandArgs { @@ -51,13 +52,14 @@ interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + target?: TargetType; force?: boolean; yes?: boolean; errors: string[]; } const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; -const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const; +const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const; const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); @@ -130,6 +132,14 @@ function extractPositionalArgs(args: string[]): string[] { return positionals; } +function parseTargetValue(value: string): TargetType | null { + const normalized = value.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; +} + /** Parse command line arguments for api commands */ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { @@ -184,6 +194,22 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { } ); + remaining = applyRepeatedOption( + remaining, + ['--target'], + (value) => { + const target = parseTargetValue(value); + if (!target) { + result.errors.push(`Invalid --target value "${value}". Use: claude or droid`); + return; + } + result.target = target; + }, + () => { + result.errors.push('Missing value for --target'); + } + ); + const positionalArgs = extractPositionalArgs(remaining); result.name = positionalArgs[0]; return result; @@ -383,12 +409,23 @@ async function handleCreate(args: string[]): Promise { sonnet: sonnetModel, haiku: haikuModel, }; + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this profile?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } // Create profile console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl, apiKey, models); + const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); @@ -411,7 +448,8 @@ async function handleCreate(args: string[]): Promise { `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}`; + `Model: ${model}\n` + + `Target: ${resolvedTarget}`; if (hasCustomMapping) { infoMsg += @@ -424,7 +462,24 @@ async function handleCreate(args: string[]): Promise { console.log(infoBox(infoMsg, 'API Profile Created')); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(header('Edit Settings')); console.log(` ${dim('To modify env vars later:')}`); @@ -453,13 +508,13 @@ async function handleList(): Promise { // Build table data const rows: string[][] = profiles.map((p) => { const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); - return [p.name, p.settingsPath, status]; + return [p.name, p.target, p.settingsPath, status]; }); - const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10]; + const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10]; console.log( table(rows, { - head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], + head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], colWidths, }) ); @@ -468,11 +523,11 @@ async function handleList(): Promise { // Show CLIProxy variants if any if (variants.length > 0) { console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]); + const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]); console.log( table(cliproxyRows, { - head: ['Variant', 'Provider', 'Settings'], - colWidths: [15, 15, 30], + head: ['Variant', 'Provider', 'Target', 'Settings'], + colWidths: [15, 12, 10, 28], }) ); console.log(''); @@ -582,6 +637,9 @@ async function showHelp(): Promise { console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--target ', 'command')} Default target: claude or droid (create)` + ); console.log(` ${color('--force', 'command')} Overwrite existing (create)`); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); console.log(''); @@ -605,6 +663,7 @@ async function showHelp(): Promise { console.log(''); console.log(` ${dim('# Create with name')}`); console.log(` ${color('ccs api create myapi', 'command')}`); + console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); console.log(''); console.log(` ${dim('# Remove API profile')}`); console.log(` ${color('ccs api remove myapi', 'command')}`); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index b8a0dc82..fc5ac004 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -35,4 +35,19 @@ describe('api-command arg parser', () => { expect(parsed.yes).toBe(true); expect(parsed.name).toBe('-my-api'); }); + + test('parses --target for default profile target', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('validates invalid --target values', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']); + }); }); From f4a692729300d0ec7894b985c57a9d3a5cfc5a52 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:05 +0700 Subject: [PATCH 18/94] feat(cliproxy): persist target on variants - add target to variant create/update flows for single and composite modes - persist target in unified config and legacy cliproxy entries - include target in variant list/remove return payloads --- .../services/variant-config-adapter.ts | 69 ++++++++----------- src/cliproxy/services/variant-service.ts | 43 +++++++++--- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 1b1b3f15..e8179b51 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -1,12 +1,7 @@ -/** - * CLIProxy Variant Config Adapters - * - * Handles reading/writing variant config in both unified and legacy formats. - */ - import * as fs from 'fs'; import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { CLIProxyProvider } from '../types'; +import type { TargetType } from '../../targets/target-adapter'; import { CLIProxyVariantConfig, CompositeVariantConfig, @@ -20,19 +15,15 @@ import { } from '../../config/unified-config-loader'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; -/** First port for variant profiles (8318 = default + 1) */ export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1; - -/** Maximum port offset for variants (100 ports: 8318-8417) */ export const VARIANT_PORT_MAX_OFFSET = 100; - -/** Variant configuration structure */ export interface VariantConfig { provider: string; settings?: string; account?: string; model?: string; port?: number; + target?: TargetType; /** Composite variant fields */ type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; @@ -45,9 +36,6 @@ export interface VariantConfig { hasFallback?: boolean; } -/** - * Check if variant exists in config - */ export function variantExistsInConfig(name: string): boolean { try { if (isUnifiedMode()) { @@ -61,10 +49,6 @@ export function variantExistsInConfig(name: string): boolean { } } -/** - * Get next available port for a new variant. - * Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE. - */ export function getNextAvailablePort(): number { const variants = listVariantsFromConfig(); const usedPorts = new Set(); @@ -89,9 +73,6 @@ export function getNextAvailablePort(): number { ); } -/** - * List variants from config - */ export function listVariantsFromConfig(): Record { try { if (isUnifiedMode()) { @@ -139,6 +120,7 @@ export function listVariantsFromConfig(): Record { provider: defaultTierConfig.provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: normalizedTiers, @@ -151,6 +133,7 @@ export function listVariantsFromConfig(): Record { settings: single.settings, account: single.account, port: single.port, + target: single.target || 'claude', }; } } catch (error) { @@ -171,12 +154,14 @@ export function listVariantsFromConfig(): Record { settings: string; account?: string; port?: number; + target?: TargetType; }; result[name] = { provider: v.provider, settings: v.settings, account: v.account, port: v.port, + target: v.target || 'claude', }; } return result; @@ -185,9 +170,6 @@ export function listVariantsFromConfig(): Record { } } -/** - * Save composite variant to unified config - */ export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void { const unifiedConfig = loadOrCreateUnifiedConfig(); @@ -206,15 +188,13 @@ export function saveCompositeVariantUnified(name: string, config: CompositeVaria saveUnifiedConfig(unifiedConfig); } -/** - * Save variant to unified config - */ export function saveVariantUnified( name: string, provider: CLIProxyProvider, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const config = loadOrCreateUnifiedConfig(); @@ -234,20 +214,19 @@ export function saveVariantUnified( account, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveUnifiedConfig(config); } -/** - * Save variant to legacy JSON config - */ export function saveVariantLegacy( name: string, provider: string, settingsPath: string, account?: string, - port?: number + port?: number, + target: TargetType = 'claude' ): void { const configPath = getConfigPath(); @@ -262,7 +241,13 @@ export function saveVariantLegacy( config.cliproxy = {}; } - const variantConfig: { provider: string; settings: string; account?: string; port?: number } = { + const variantConfig: { + provider: string; + settings: string; + account?: string; + port?: number; + target?: TargetType; + } = { provider, settings: settingsPath, }; @@ -272,6 +257,9 @@ export function saveVariantLegacy( if (port) { variantConfig.port = port; } + if (target !== 'claude') { + variantConfig.target = target; + } config.cliproxy[name] = variantConfig; const tempPath = configPath + '.tmp'; @@ -279,9 +267,6 @@ export function saveVariantLegacy( fs.renameSync(tempPath, configPath); } -/** - * Remove variant from unified config - */ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null { const config = loadOrCreateUnifiedConfig(); @@ -299,6 +284,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: composite.tiers[composite.default_tier].provider, settings: composite.settings, port: composite.port, + target: composite.target || 'claude', type: 'composite', default_tier: composite.default_tier, tiers: composite.tiers, @@ -309,12 +295,10 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu provider: singleVariant.provider, settings: singleVariant.settings, port: singleVariant.port, + target: singleVariant.target || 'claude', }; } -/** - * Remove variant from legacy JSON config - */ export function removeVariantFromLegacyConfig(name: string): VariantConfig | null { const configPath = getConfigPath(); @@ -329,7 +313,12 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul return null; } - const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number }; + const variant = config.cliproxy[name] as { + provider: string; + settings: string; + port?: number; + target?: TargetType; + }; delete config.cliproxy[name]; if (Object.keys(config.cliproxy).length === 0) { diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index a3471c13..fbb917a6 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; +import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { DEFAULT_BACKEND } from '../platform-detector'; @@ -110,7 +111,8 @@ export function createVariant( name: string, provider: CLIProxyProfileName, model: string, - account?: string + account?: string, + target: TargetType = 'claude' ): VariantOperationResult { try { // Validate provider/backend compatibility (block kiro/ghcp on original backend) @@ -131,17 +133,25 @@ export function createVariant( provider as CLIProxyProvider, getRelativeSettingsPath(provider, name), account, - port + port, + target ); } else { settingsPath = createSettingsFile(name, provider, model, port); - saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port); + saveVariantLegacy( + name, + provider, + `~/.ccs/${path.basename(settingsPath)}`, + account, + port, + target + ); } return { success: true, settingsPath, - variant: { provider, model, account, port }, + variant: { provider, model, account, port, target }, }; } catch (error) { return { @@ -208,6 +218,7 @@ export interface UpdateVariantOptions { provider?: CLIProxyProfileName; account?: string; model?: string; + target?: TargetType; } /** @@ -233,6 +244,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari const providerChanged = updates.provider !== undefined && updates.provider !== existing.provider; + const existingTarget = existing.target || 'claude'; + const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; if (providerChanged && !hasModelUpdate) { @@ -257,8 +270,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } - // Update config entry if provider or account changed - if (updates.provider !== undefined || updates.account !== undefined) { + // Update config entry if provider/account/target changed + if (updates.provider !== undefined || updates.account !== undefined || targetChanged) { const newProvider = updates.provider ?? existing.provider; // Validate provider/backend compatibility on provider change @@ -269,6 +282,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } } const newAccount = updates.account !== undefined ? updates.account : existing.account; + const newTarget = updates.target ?? existingTarget; if (isUnifiedMode()) { saveVariantUnified( @@ -276,7 +290,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider as CLIProxyProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } else { saveVariantLegacy( @@ -284,7 +299,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari newProvider, existing.settings || '', newAccount || undefined, - existing.port + existing.port, + newTarget ); } } @@ -297,6 +313,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, + target: updates.target ?? existingTarget, }, }; } catch (error) { @@ -308,6 +325,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari export interface CreateCompositeVariantOptions { name: string; defaultTier: 'opus' | 'sonnet' | 'haiku'; + target?: TargetType; tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; @@ -329,7 +347,7 @@ export function createCompositeVariant( } try { - const { name, defaultTier, tiers } = options; + const { name, defaultTier, tiers, target = 'claude' } = options; const validationError = validateCompositeTiers(tiers, { defaultTier, @@ -361,6 +379,7 @@ export function createCompositeVariant( tiers, settings: settingsPath, port, + ...(target !== 'claude' && { target }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -373,6 +392,7 @@ export function createCompositeVariant( default_tier: defaultTier, tiers, port, + target, }, }; } catch (error) { @@ -384,6 +404,7 @@ export function createCompositeVariant( export interface UpdateCompositeVariantOptions { defaultTier?: 'opus' | 'sonnet' | 'haiku'; tiers?: Partial>; + target?: TargetType; } /** @@ -418,6 +439,8 @@ export function updateCompositeVariant( }; const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + const existingTarget = existing.target || 'claude'; + const newTarget = updates.target ?? existingTarget; const validationError = validateCompositeTiers(mergedTiers, { defaultTier: newDefaultTier, requireAllTiers: true, @@ -455,6 +478,7 @@ export function updateCompositeVariant( tiers: mergedTiers, settings: settingsRef, port: existing.port, + ...(newTarget !== 'claude' && { target: newTarget }), }; saveCompositeVariantUnified(name, compositeConfig); @@ -468,6 +492,7 @@ export function updateCompositeVariant( tiers: mergedTiers, port: existing.port, settings: settingsRef, + target: newTarget, }, }; } catch (error) { From 5b60784eb82e51c0356cf416ba6323094e04d19c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:18 +0700 Subject: [PATCH 19/94] feat(cliproxy): accept target in variant routes - validate target request payloads for create and update APIs - pass target into single/composite service operations - include target in variant API responses --- src/web-server/routes/variant-routes.ts | 67 ++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index c21429ba..b6359c95 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { createVariant, removeVariant, @@ -23,6 +24,23 @@ import { const router = Router(); +function parseTarget(rawTarget: unknown): TargetType | null { + if (rawTarget === undefined || rawTarget === null || rawTarget === '') { + return null; + } + + if (typeof rawTarget !== 'string') { + return null; + } + + const normalized = rawTarget.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + + return null; +} + /** * GET /api/cliproxy - List cliproxy variants * Uses variant-service for consistent behavior with CLI @@ -36,6 +54,7 @@ router.get('/', (_req: Request, res: Response) => { account: variant.account || 'default', port: variant.port, // Include port for port isolation model: variant.model, + target: variant.target || 'claude', type: variant.type, default_tier: variant.default_tier, tiers: variant.tiers, @@ -50,6 +69,12 @@ router.get('/', (_req: Request, res: Response) => { */ router.post('/', (req: Request, res: Response): void => { const { name, provider, model, account, type, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } if (!name) { res.status(400).json({ error: 'Missing required field: name' }); @@ -91,7 +116,12 @@ router.post('/', (req: Request, res: Response): void => { let result; try { - result = createCompositeVariant({ name, defaultTier: default_tier, tiers }); + result = createCompositeVariant({ + name, + defaultTier: default_tier, + target: parsedTarget || 'claude', + tiers, + }); } catch (error) { res.status(400).json({ error: (error as Error).message }); return; @@ -109,6 +139,7 @@ router.post('/', (req: Request, res: Response): void => { tiers, settings: result.settingsPath, port: result.variant?.port, + target: result.variant?.target || 'claude', }); return; } @@ -126,7 +157,13 @@ router.post('/', (req: Request, res: Response): void => { } // Use variant-service for proper port allocation - const result = createVariant(name, provider as CLIProxyProvider, model, account); + const result = createVariant( + name, + provider as CLIProxyProvider, + model, + account, + parsedTarget || 'claude' + ); if (!result.success) { res.status(409).json({ error: result.error }); @@ -140,6 +177,7 @@ router.post('/', (req: Request, res: Response): void => { account: account || 'default', port: result.variant?.port, model: result.variant?.model, + target: result.variant?.target || 'claude', }); }); @@ -154,6 +192,12 @@ router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; const { provider, account, model, default_tier, tiers } = req.body; + const parsedTarget = parseTarget(req.body.target); + + if (req.body.target !== undefined && parsedTarget === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } // Check if variant is composite - use updateCompositeVariant if so const variants = listVariants(); @@ -165,8 +209,8 @@ router.put('/:name', (req: Request, res: Response): void => { } if (existing.type === 'composite') { - if (!default_tier && !tiers) { - res.status(400).json({ error: 'Must provide at least default_tier or tiers' }); + if (!default_tier && !tiers && req.body.target === undefined) { + res.status(400).json({ error: 'Must provide at least default_tier, tiers, or target' }); return; } @@ -189,7 +233,11 @@ router.put('/:name', (req: Request, res: Response): void => { } } - const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); + const result = updateCompositeVariant(name, { + defaultTier: default_tier, + tiers, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -207,13 +255,19 @@ router.put('/:name', (req: Request, res: Response): void => { tiers: persisted?.tiers, settings: persisted?.settings, port: persisted?.port, + target: persisted?.target || 'claude', updated: true, }); return; } // Use variant-service for proper update handling (single provider) - const result = updateVariant(name, { provider, account, model }); + const result = updateVariant(name, { + provider, + account, + model, + target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined, + }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; @@ -227,6 +281,7 @@ router.put('/:name', (req: Request, res: Response): void => { account: result.variant?.account || 'default', settings: result.variant?.settings, port: result.variant?.port, + target: result.variant?.target || 'claude', updated: true, }); } catch (error) { From d2d1d599cd220fb1e2c1eae9a501148b97f0770b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:29 +0700 Subject: [PATCH 20/94] feat(cliproxy): add target support to subcommands - add --target parsing and validation in create/edit/remove flows - show target in list/remove/edit UX and usage examples - document target option in cliproxy help output --- src/commands/cliproxy/auth-subcommand.ts | 7 +- src/commands/cliproxy/help-subcommand.ts | 1 + src/commands/cliproxy/variant-subcommand.ts | 173 +++++++++++++++++++- 3 files changed, 172 insertions(+), 9 deletions(-) diff --git a/src/commands/cliproxy/auth-subcommand.ts b/src/commands/cliproxy/auth-subcommand.ts index 76ab6730..b856f278 100644 --- a/src/commands/cliproxy/auth-subcommand.ts +++ b/src/commands/cliproxy/auth-subcommand.ts @@ -45,10 +45,13 @@ export async function handleList(): Promise { const variant = variants[name]; const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider; const portStr = variant.port ? String(variant.port) : '-'; - return [name, providerDisplay, portStr, variant.settings || '-']; + return [name, providerDisplay, variant.target || 'claude', portStr, variant.settings || '-']; }); console.log( - table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] }) + table(rows, { + head: ['Variant', 'Provider', 'Target', 'Port', 'Settings'], + colWidths: [15, 12, 10, 8, 24], + }) ); console.log(''); console.log(dim(`Total: ${variantNames.length} custom variant(s)`)); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 01d5bd13..cc3e60a2 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -81,6 +81,7 @@ export async function showHelp(): Promise { 'Options:', [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], + ['--target ', 'Default target for created/edited variants: claude | droid'], ['--verbose, -v', 'Show detailed quota fetch diagnostics'], ], ], diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fa395445..53171b20 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -12,6 +12,7 @@ import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; +import type { TargetType } from '../../targets/target-adapter'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; @@ -32,13 +33,23 @@ interface CliproxyProfileArgs { provider?: CLIProxyProfileName; model?: string; account?: string; + target?: TargetType; force?: boolean; yes?: boolean; composite?: boolean; + errors: string[]; +} + +function parseTargetValue(rawValue: string): TargetType | null { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'claude' || normalized === 'droid') { + return normalized; + } + return null; } function parseProfileArgs(args: string[]): CliproxyProfileArgs { - const result: CliproxyProfileArgs = {}; + const result: CliproxyProfileArgs = { errors: [] }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--provider' && args[i + 1]) { @@ -47,6 +58,27 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.model = args[++i]; } else if (arg === '--account' && args[i + 1]) { result.account = args[++i]; + } else if (arg === '--target') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-')) { + result.errors.push('Missing value for --target'); + } else { + i += 1; + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } + } + } else if (arg.startsWith('--target=')) { + const rawValue = arg.slice('--target='.length); + const parsedTarget = parseTargetValue(rawValue); + if (!parsedTarget) { + result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`); + } else { + result.target = parsedTarget; + } } else if (arg === '--force') { result.force = true; } else if (arg === '--yes' || arg === '-y') { @@ -145,6 +177,11 @@ export async function handleCreate( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } console.log(header(`Create ${getBackendLabel(backend)} Variant`)); console.log(''); @@ -168,6 +205,17 @@ export async function handleCreate( process.exit(1); } + let resolvedTarget: TargetType = parsedArgs.target || 'claude'; + if (!parsedArgs.target && !parsedArgs.yes) { + const useDroidByDefault = await InteractivePrompt.confirm( + 'Set default target to Factory Droid for this variant?', + { default: false } + ); + if (useDroidByDefault) { + resolvedTarget = 'droid'; + } + } + // Composite mode: select provider+model per tier if (parsedArgs.composite) { console.log(info('Composite variant — select provider and model for each tier')); @@ -203,6 +251,7 @@ export async function handleCreate( const result = createCompositeVariant({ name, defaultTier, + target: resolvedTarget, tiers: { opus, sonnet, haiku }, }); @@ -217,7 +266,8 @@ export async function handleCreate( ? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` + `Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` + `Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` + - `Default: ${defaultTier}` + `Default: ${defaultTier}\n` + + `Target: ${resolvedTarget}` : ''; const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : ''; console.log( @@ -228,7 +278,24 @@ export async function handleCreate( ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); return; } @@ -350,7 +417,7 @@ export async function handleCreate( // Create variant console.log(''); console.log(info(`Creating ${getBackendLabel(backend)} variant...`)); - const result = createVariant(name, provider, model, account); + const result = createVariant(name, provider, model, account, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create variant: ${result.error}`)); @@ -367,13 +434,30 @@ export async function handleCreate( const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : ''; console.log( infoBox( - `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, + `Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`, configType ) ); console.log(''); console.log(header('Usage')); - console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } console.log(''); console.log(dim('To change model later:')); console.log(` ${color(`ccs ${name} --config`, 'command')}`); @@ -383,6 +467,11 @@ export async function handleCreate( export async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -435,6 +524,7 @@ export async function handleRemove(args: string[]): Promise { if (variant.port) { console.log(` Port: ${variant.port}`); } + console.log(` Target: ${variant.target || 'claude'}`); console.log(` Settings: ${variant.settings || '-'}`); console.log(''); @@ -461,6 +551,11 @@ export async function handleEdit( ): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exitCode = 1; + return; + } const variants = listVariants(); const variantNames = Object.keys(variants); @@ -501,12 +596,14 @@ export async function handleEdit( // If not composite, use existing updateVariant() flow (interactive prompts) if (variant.type !== 'composite') { + const currentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Variant: ${name}`)); console.log(''); console.log(`Current provider: ${variant.provider}`); if (variant.model) { console.log(`Current model: ${variant.model}`); } + console.log(`Current target: ${currentTarget}`); console.log(''); const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false }); @@ -552,6 +649,22 @@ export async function handleEdit( } } + let newTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newTarget = (await InteractivePrompt.selectFromList('Select target:', targetOptions, { + defaultIndex: currentTarget === 'droid' ? 1 : 0, + })) as TargetType; + } + } + console.log(''); console.log(info(`Updating ${getBackendLabel(backend)} variant...`)); // Use existing updateVariant from variant-service for single-provider variants @@ -559,6 +672,7 @@ export async function handleEdit( const result = updateVariant(name, { provider: newProvider, model: changeModel ? newModel : undefined, + target: newTarget, }); if (!result.success) { @@ -566,13 +680,35 @@ export async function handleEdit( process.exit(1); } + const resolvedTarget = result.variant?.target || currentTarget; console.log(''); console.log(ok(`Variant updated: ${name}`)); console.log(''); + console.log(header('Usage')); + if (resolvedTarget === 'droid') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); + } else { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` + ); + console.log( + ` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}` + ); + } + console.log(''); return; } // Composite variant edit flow + const compositeCurrentTarget: TargetType = variant.target || 'claude'; console.log(header(`Edit Composite Variant: ${name}`)); console.log(''); if (!variant.tiers) { @@ -585,6 +721,7 @@ export async function handleEdit( console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`); console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`); console.log(` Default: ${variant.default_tier}`); + console.log(` Target: ${compositeCurrentTarget}`); console.log(''); const verbose = args.includes('--verbose'); @@ -634,11 +771,32 @@ export async function handleEdit( )) as 'opus' | 'sonnet' | 'haiku'; } + let newCompositeTarget: TargetType | undefined = parsedArgs.target; + if (!parsedArgs.target) { + const changeTarget = await InteractivePrompt.confirm('Change default target?', { + default: false, + }); + if (changeTarget) { + const targetOptions = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'droid', label: 'Factory Droid' }, + ]; + newCompositeTarget = (await InteractivePrompt.selectFromList( + 'Select target:', + targetOptions, + { + defaultIndex: compositeCurrentTarget === 'droid' ? 1 : 0, + } + )) as TargetType; + } + } + console.log(''); console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`)); const result = updateCompositeVariant(name, { tiers: updatedTiers, defaultTier: changeDefault ? newDefaultTier : undefined, + target: newCompositeTarget, }); if (!result.success) { @@ -653,7 +811,8 @@ export async function handleEdit( `Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` + `Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` + `Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` + - `Default: ${finalVariant.default_tier}`; + `Default: ${finalVariant.default_tier}\n` + + `Target: ${finalVariant.target || compositeCurrentTarget}`; const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : ''; console.log( infoBox( From 8a2a7c3eb0c86aa1207099d9f67fb57840860673 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:40 +0700 Subject: [PATCH 21/94] feat(runtime): run cliproxy profiles on droid - execute cliproxy profiles on droid using resolved proxy env credentials - enforce auth/service preconditions and block Claude-only management flags - allow dotted profile names in droid adapter/config manager --- src/ccs.ts | 147 ++++++++++++++++++++- src/targets/droid-adapter.ts | 10 +- src/targets/droid-config-manager.ts | 6 +- tests/unit/targets/target-registry.test.ts | 4 +- 4 files changed, 153 insertions(+), 14 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 20f69d54..94e096e6 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -12,7 +12,14 @@ import { import { expandPath } from './utils/helpers'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; -import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { + execClaudeWithCLIProxy, + CLIProxyProvider, + ensureCliproxyService, + isAuthenticated, +} from './cliproxy'; +import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; +import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; import { ensureMcpWebSearch, displayWebSearchStatus, @@ -694,7 +701,9 @@ async function main(): Promise { if (resolvedTarget === 'droid') { try { const allProfiles = detector.getAllProfiles(); - const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name)); + const activeProfiles = allProfiles.settings.filter((name) => + /^[a-zA-Z0-9._-]+$/.test(name) + ); await pruneOrphanedModels(activeProfiles); } catch (error) { console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); @@ -724,9 +733,141 @@ async function main(): Promise { const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation + const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT; + + if (resolvedTarget !== 'claude') { + const adapter = targetAdapter; + if (!adapter) { + console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); + process.exitCode = 1; + return; + } + if (!adapter.supportsProfileType('cliproxy')) { + console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); + process.exitCode = 1; + return; + } + + // Keep CLIProxy management/auth flags on Claude flow only. + const unsupportedCliproxyFlags = [ + '--auth', + '--logout', + '--accounts', + '--add', + '--use', + '--config', + '--headless', + '--paste-callback', + '--port-forward', + '--nickname', + '--kiro-auth-method', + '--backend', + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--proxy-timeout', + '--local-proxy', + '--remote-only', + '--no-fallback', + '--allow-self-signed', + '--thinking', + '--effort', + '--1m', + '--no-1m', + ]; + const providedUnsupportedFlag = unsupportedCliproxyFlags.find( + (flag) => + remainingArgs.includes(flag) || remainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + ); + if (providedUnsupportedFlag) { + console.error( + fail( + `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` + ) + ); + console.error( + info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`) + ); + process.exitCode = 1; + return; + } + + // For Droid execution path, require existing OAuth auth and running local proxy. + if (profileInfo.isComposite && profileInfo.compositeTiers) { + const compositeProviders = [ + ...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)), + ] as CLIProxyProvider[]; + const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); + if (missingProvider) { + console.error( + fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`) + ); + console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); + process.exitCode = 1; + return; + } + } else if (!isAuthenticated(provider)) { + console.error(fail(`No OAuth authentication found for provider: ${provider}`)); + console.error(info(`Authenticate first: ccs ${provider} --auth`)); + process.exitCode = 1; + return; + } + + const ensureServiceResult = await ensureCliproxyService( + cliproxyPort, + remainingArgs.includes('--verbose') || remainingArgs.includes('-v') + ); + if (!ensureServiceResult.started) { + console.error( + fail(ensureServiceResult.error || 'Failed to start local CLIProxy service') + ); + process.exitCode = 1; + return; + } + + const envVars = + profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier + ? getCompositeEnvVars( + profileInfo.compositeTiers, + profileInfo.compositeDefaultTier, + cliproxyPort, + customSettingsPath + ) + : getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath); + + const creds: TargetCredentials = { + profile: profileInfo.name, + baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', + apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', + model: envVars['ANTHROPIC_MODEL'] || undefined, + provider: 'anthropic', + envVars, + }; + + if (!creds.baseUrl || !creds.apiKey) { + console.error( + fail( + `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` + ) + ); + console.error( + info('Reconfigure with: ccs config > CLIProxy, or run ccs --config') + ); + process.exitCode = 1; + return; + } + + await adapter.prepareCredentials(creds); + const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetEnv = adapter.buildEnv(creds, profileInfo.type); + adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); + return; + } + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath, - port: variantPort, + port: cliproxyPort, isComposite: profileInfo.isComposite, compositeTiers: profileInfo.compositeTiers, compositeDefaultTier: profileInfo.compositeDefaultTier, diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index bb3a8008..1a89ca20 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -53,9 +53,9 @@ export class DroidAdapter implements TargetAdapter { } buildArgs(profile: string, userArgs: string[]): string[] { - if (!/^[a-zA-Z0-9_-]+$/.test(profile)) { + if (!/^[a-zA-Z0-9._-]+$/.test(profile)) { throw new Error( - `Invalid profile name "${profile}" for Droid target: only alphanumeric, underscore, hyphen allowed` + `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } return ['-m', `custom:ccs-${profile}`, ...userArgs]; @@ -154,10 +154,8 @@ export class DroidAdapter implements TargetAdapter { }); } - /** - * Droid currently supports direct settings-based and default flows only. - */ + /** Droid supports settings/default and CLIProxy-executed profile flows. */ supportsProfileType(profileType: ProfileType): boolean { - return profileType === 'settings' || profileType === 'default'; + return profileType === 'settings' || profileType === 'default' || profileType === 'cliproxy'; } } diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 3483a2d3..8e61eec4 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -20,16 +20,16 @@ const LOCK_RETRY_MAX_MS = 1000; /** * Validate profile name to prevent filesystem/security issues. - * Only alphanumeric, underscore, hyphen allowed. + * Only alphanumeric, dot, underscore, hyphen allowed. */ function isValidProfileName(profile: string): boolean { - return !!profile && /^[a-zA-Z0-9_-]+$/.test(profile); + return !!profile && /^[a-zA-Z0-9._-]+$/.test(profile); } function validateProfileName(profile: string): void { if (!isValidProfileName(profile)) { throw new Error( - `Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens` + `Invalid profile name "${profile}": must contain only alphanumeric characters, dots, underscores, or hyphens` ); } } diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index d6485d49..2a7e7924 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -120,8 +120,8 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('default')).toBe(true); }); - it('should NOT support cliproxy and copilot profile types', () => { - expect(adapter.supportsProfileType('cliproxy')).toBe(false); + it('should support cliproxy and NOT support copilot profile type', () => { + expect(adapter.supportsProfileType('cliproxy')).toBe(true); expect(adapter.supportsProfileType('copilot')).toBe(false); }); From 9a63f9bd36d6c53131b1a7681c7c7658516cce46 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:52:50 +0700 Subject: [PATCH 22/94] feat(config): support legacy profile target overrides - add profile_targets map to legacy config typing - load target defaults for settings/default profiles from config.json - expose target metadata for legacy cliproxy variants in profile detection --- src/auth/profile-detector.ts | 7 +++++++ src/types/config.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index a705200b..06bdb788 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -309,12 +309,15 @@ class ProfileDetector { // Priority 2: Check user-defined CLIProxy variants (config.cliproxy section) const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.cliproxy && config.cliproxy[profileName]) { const variant = config.cliproxy[profileName]; return { type: 'cliproxy', name: profileName, + target: variant.target, provider: variant.provider as CLIProxyProfileName, settingsPath: variant.settings, port: variant.port, @@ -333,6 +336,7 @@ class ProfileDetector { type: 'settings', name: profileName, settingsPath: config.profiles[candidate], + target: legacyTargetMap?.[candidate], message: viaLegacyAlias ? `Using legacy API profile "${candidate}" for "${profileName}".` : undefined, @@ -392,6 +396,8 @@ class ProfileDetector { // Check if settings-based default exists const config = this.readConfig(); + const legacyTargetMap = (config as { profile_targets?: Record }) + .profile_targets; if (config.profiles && config.profiles['default']) { const settingsPath = config.profiles['default']; @@ -409,6 +415,7 @@ class ProfileDetector { type: 'settings', name: 'default', settingsPath, + target: legacyTargetMap?.['default'], }; } diff --git a/src/types/config.ts b/src/types/config.ts index 2565bd79..c385eef9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -4,6 +4,7 @@ */ import type { CLIProxyProvider } from '../cliproxy/types'; +import type { TargetType } from '../targets/target-adapter'; /** * Profile configuration mapping @@ -27,6 +28,8 @@ export interface CLIProxyVariantConfig { account?: string; /** Unique port for variant isolation (8318-8417) */ port?: number; + /** Target CLI to use for this variant (default: claude) */ + target?: TargetType; } /** @@ -44,6 +47,8 @@ export interface CLIProxyVariantsConfig { export interface Config { /** Settings-based profiles (GLM, Kimi, etc.) */ profiles: ProfilesConfig; + /** Per-profile CLI target overrides (legacy mode) */ + profile_targets?: Record; /** User-defined CLIProxy profile variants (optional) */ cliproxy?: CLIProxyVariantsConfig; } From ca78e63205aeb7cbd6c059c7f70fffcc509f860d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:02 +0700 Subject: [PATCH 23/94] feat(ui): add target support to API profile dashboard - extend API client types with claude|droid target fields - add default target selector in API profile create dialog - show target badges in API profile list --- .../profiles/profile-create-dialog.tsx | 35 +++++++++++++++++++ ui/src/lib/api-client.ts | 8 +++++ ui/src/pages/api.tsx | 9 ++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 6bb32bf1..510e165a 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -12,6 +12,13 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Dialog, DialogContent, @@ -41,6 +48,7 @@ import { getNewestModelsPerProvider, } from '@/lib/openrouter-utils'; import type { CategorizedModel } from '@/lib/openrouter-types'; +import type { CliTarget } from '@/lib/api-client'; const schema = z.object({ name: z @@ -53,6 +61,7 @@ const schema = z.object({ opusModel: z.string().optional(), sonnetModel: z.string().optional(), haikuModel: z.string().optional(), + target: z.enum(['claude', 'droid']), }); type FormData = z.infer; @@ -78,6 +87,7 @@ const EMPTY_FORM_VALUES: FormData = { opusModel: '', sonnetModel: '', haikuModel: '', + target: 'claude', }; const RECOMMENDED_PRESETS = getPresetsByCategory('recommended'); @@ -117,6 +127,7 @@ export function ProfileCreateDialog({ }); const baseUrlValue = useWatch({ control, name: 'baseUrl' }); + const targetValue = useWatch({ control, name: 'target' }); const applyPresetToForm = useCallback( (preset: ProviderPreset | null) => { if (!preset) { @@ -445,6 +456,30 @@ export function ProfileCreateDialog({ ) )} + +
+ + +

+ Run with{' '} + + {targetValue === 'droid' ? 'ccsd' : 'ccs'} + {' '} + by default. You can still override each run with{' '} + --target. +

+
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 646d39f0..12ec590e 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -94,10 +94,13 @@ async function request(url: string, options?: RequestInit): Promise { } // Types +export type CliTarget = 'claude' | 'droid'; + export interface Profile { name: string; settingsPath: string; configured: boolean; + target?: CliTarget; } export interface CreateProfile { @@ -108,6 +111,7 @@ export interface CreateProfile { opusModel?: string; sonnetModel?: string; haikuModel?: string; + target?: CliTarget; } export interface UpdateProfile { @@ -117,6 +121,7 @@ export interface UpdateProfile { opusModel?: string; sonnetModel?: string; haikuModel?: string; + target?: CliTarget; } export interface Variant { @@ -126,6 +131,7 @@ export interface Variant { account?: string; port?: number; model?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { @@ -140,6 +146,7 @@ export interface CreateVariant { provider: CLIProxyProvider; model?: string; account?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { @@ -153,6 +160,7 @@ export interface UpdateVariant { provider?: CLIProxyProvider; model?: string; account?: string; + target?: CliTarget; type?: 'composite'; default_tier?: 'opus' | 'sonnet' | 'haiku'; tiers?: { diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 0152d04a..1d56ac11 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -7,6 +7,7 @@ import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; import { Plus, Search, @@ -222,6 +223,7 @@ export function ApiPage() { setDeleteConfirm(selectedProfileData.name)} onHasChangesUpdate={setEditorHasChanges} /> @@ -308,7 +310,12 @@ function ProfileListItem({ {/* Profile info */}
-
{profile.name}
+
+
{profile.name}
+ + {profile.target || 'claude'} + +
{profile.settingsPath} From db38ccc117e59fdc9f70c584aaa569b7f70cbe4a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:22 +0700 Subject: [PATCH 24/94] feat(ui): support editing profile default target - add target selector in profile editor header - call profile update API to persist target changes - update info panel usage snippets for target-aware commands --- .../profiles/editor/friendly-ui-section.tsx | 5 +- .../profiles/editor/header-section.tsx | 27 ++++++++++ ui/src/components/profiles/editor/index.tsx | 49 ++++++++++++++++++- .../profiles/editor/info-section.tsx | 46 ++++++++++++++++- ui/src/components/profiles/editor/types.ts | 3 ++ 5 files changed, 127 insertions(+), 3 deletions(-) diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx index f193290c..7a5bdeed 100644 --- a/ui/src/components/profiles/editor/friendly-ui-section.tsx +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -20,9 +20,11 @@ import { isOpenRouterProfile, extractTierMapping, applyTierMapping } from './uti import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { Settings, SettingsResponse } from './types'; +import type { CliTarget } from '@/lib/api-client'; interface FriendlyUISectionProps { profileName: string; + target: CliTarget; data: SettingsResponse | undefined; currentSettings: Settings | undefined; newEnvKey: string; @@ -36,6 +38,7 @@ interface FriendlyUISectionProps { export function FriendlyUISection({ profileName, + target, data, currentSettings, newEnvKey, @@ -263,7 +266,7 @@ export function FriendlyUISection({ value="info" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden" > - +
diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 25b2095d..6692222a 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -5,19 +5,30 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react'; import { OpenRouterBadge } from '@/components/profiles/openrouter-badge'; import { isOpenRouterProfile } from './utils'; import type { Settings } from './types'; +import type { CliTarget } from '@/lib/api-client'; interface HeaderSectionProps { profileName: string; + target: CliTarget; data: { path?: string; mtime: number } | undefined; settings?: Settings; isLoading: boolean; isSaving: boolean; + isTargetSaving: boolean; hasChanges: boolean; isRawJsonValid: boolean; + onTargetChange: (target: CliTarget) => void; onRefresh: () => void; onDelete?: () => void; onSave: () => void; @@ -25,12 +36,15 @@ interface HeaderSectionProps { export function HeaderSection({ profileName, + target, data, settings, isLoading, isSaving, + isTargetSaving, hasChanges, isRawJsonValid, + onTargetChange, onRefresh, onDelete, onSave, @@ -52,6 +66,19 @@ export function HeaderSection({ Last modified: {new Date(data.mtime).toLocaleString()}

)} +
+ Default target: + + {isTargetSaving && } +
+
+ +
+ + {isDroidTarget + ? `ccsd ${profileName} "prompt"` + : `ccs ${profileName} --target droid "prompt"`} + + +
+
+
+ +
+ + ccs {profileName} --target claude "prompt" + + +
+
diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts index 950ae645..76aa9a8d 100644 --- a/ui/src/components/profiles/editor/types.ts +++ b/ui/src/components/profiles/editor/types.ts @@ -2,6 +2,8 @@ * Types for Profile Editor */ +import type { CliTarget } from '@/lib/api-client'; + export interface Settings { env?: Record; } @@ -15,6 +17,7 @@ export interface SettingsResponse { export interface ProfileEditorProps { profileName: string; + profileTarget?: CliTarget; onDelete?: () => void; onHasChangesUpdate?: (hasChanges: boolean) => void; } From 543ec5f0502e6c9cb24e0837b72e6c4d82f15c4d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:53:40 +0700 Subject: [PATCH 25/94] feat(ui): add target controls to cliproxy variants - add default target selectors in create/edit dialogs - include target in variant create/update payloads - display target badge column in variants table --- .../components/cliproxy/cliproxy-dialog.tsx | 29 +++++++++++++++++++ .../cliproxy/cliproxy-edit-dialog.tsx | 29 +++++++++++++++++++ ui/src/components/cliproxy/cliproxy-table.tsx | 9 ++++++ 3 files changed, 67 insertions(+) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 0642596a..879c5e07 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -26,6 +26,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), + target: z.enum(['claude', 'droid']).default('claude'), }); const compositeSchema = z.object({ @@ -34,6 +35,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), + target: z.enum(['claude', 'droid']).default('claude'), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -74,12 +76,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const singleForm = useForm({ resolver: zodResolver(singleProviderSchema), + defaultValues: { target: 'claude' }, }); const compositeForm = useForm({ resolver: zodResolver(compositeSchema), defaultValues: { default_tier: 'opus', + target: 'claude', tiers: { opus: { provider: 'gemini', model: '' }, sonnet: { provider: 'gemini', model: '' }, @@ -107,6 +111,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { await createMutation.mutateAsync({ name: data.name, provider: data.tiers[data.default_tier].provider, + target: data.target, type: 'composite', default_tier: data.default_tier, tiers: data.tiers, @@ -200,6 +205,18 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
@@ -74,6 +88,18 @@ export function ProviderInfoTab({ provider, displayName, data, authStatus }: Pro

Quick Usage

+ + diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 0c154ca6..e470dcbf 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -2,7 +2,7 @@ * Type definitions for ProviderEditor components */ -import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; export interface SettingsResponse { @@ -27,6 +27,8 @@ export interface ProviderEditorProps { isRemoteMode?: boolean; /** Port number for variant (for display in header) */ port?: number; + /** Default execution target for this profile/variant */ + defaultTarget?: CliTarget; onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5d12c178..e75d645c 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -120,6 +120,9 @@ function VariantSidebarItem({ variant + + {variant.target || 'claude'} +
{parentAuth?.authenticated ? ( @@ -405,6 +408,7 @@ export function CliproxyPage() { catalog={MODEL_CATALOGS[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} baseProvider={selectedVariantData.provider} + defaultTarget={selectedVariantData.target} isRemoteMode={isRemoteMode} port={selectedVariantData.port} onAddAccount={() => From 172e5995747c31acce3da4ab395e5581d7130e65 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:54:22 +0700 Subject: [PATCH 27/94] docs(cli): document target defaults and ccsd cliproxy usage - add multi-target help examples for ccsd codex and ccsd agy - document per-profile target defaults for API and CLIProxy profiles - include dashboard parity notes for target configuration --- README.md | 24 ++++++++++++++++++++++++ src/commands/help-command.ts | 6 ++++++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index 9c173863..a51427c1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,30 @@ ccsd glm Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). +### Per-Profile Target Defaults + +You can pin a default target (`claude` or `droid`) per profile: + +```bash +# API profile defaults to Droid +ccs api create myglm --preset glm --target droid + +# CLIProxy variant defaults to Droid +ccs cliproxy create mycodex --provider codex --target droid +``` + +Built-in CLIProxy providers also work with Droid alias/target override: + +```bash +ccsd codex +ccsd agy +ccs codex --target droid +``` + +Dashboard parity: +- `ccs config` -> `API Profiles` -> set **Default Target** +- `ccs config` -> `CLIProxy` -> create/edit variant -> set **Default Target** + ### Kiro Auth Methods `ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 1fdc3d63..38953277 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -326,6 +326,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('Multi-Target', [ ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], ['ccsd glm', 'Same as above (alias)'], + ['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccs cliproxy create my-codex --provider codex --target droid', + 'Create CLIProxy variant with Droid as default target', + ], ['ccs glm', 'Run GLM profile on Claude Code (default)'], ]); From 1c7e4e116f05fff4802c5b2b228cdffebe4e2228 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 15:59:33 +0700 Subject: [PATCH 28/94] fix(ui): align cliproxy target schema typing - remove zod default() on target fields in cliproxy forms - keep target defaults via react-hook-form defaultValues - resolve ci-parity TypeScript resolver mismatch errors --- ui/src/components/cliproxy/cliproxy-dialog.tsx | 4 ++-- ui/src/components/cliproxy/cliproxy-edit-dialog.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 879c5e07..6420fc58 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -26,7 +26,7 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), }); const compositeSchema = z.object({ @@ -35,7 +35,7 @@ const compositeSchema = z.object({ .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 0e95ea44..3f4c4f1b 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -20,12 +20,12 @@ const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), }); const compositeSchema = z.object({ default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), - target: z.enum(['claude', 'droid']).default('claude'), + target: z.enum(['claude', 'droid']), tiers: z.object({ opus: z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), From e32dedcf4c0f45470303eba6ed66e10c21097c72 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:00:39 +0700 Subject: [PATCH 29/94] fix(api): remove stale legacy profile target mappings - delete profile_targets entry when deleting a legacy API profile - prune empty profile_targets object to avoid config drift --- src/api/services/profile-writer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index fa8ff9c4..f90455c2 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -251,6 +251,12 @@ function removeApiProfileUnified(name: string): void { function removeApiProfileLegacy(name: string): void { const config = loadConfigSafe(); delete config.profiles[name]; + if (config.profile_targets) { + delete config.profile_targets[name]; + if (Object.keys(config.profile_targets).length === 0) { + delete config.profile_targets; + } + } const configPath = getConfigPath(); const tempPath = configPath + '.tmp'; From 2bd3c40c7ad4072634b395c0c5cfa7a29a2657a2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:00:49 +0700 Subject: [PATCH 30/94] fix(targets): sanitize invalid persisted target values - fallback unknown persisted targets to claude in profile listing and runtime resolution - add regression test for invalid profile target fallback --- src/api/services/profile-reader.ts | 21 +++++++++++++++------ src/targets/target-resolver.ts | 10 +++++++--- tests/unit/targets/target-resolver.test.ts | 5 +++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 028931c7..190426da 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -12,6 +12,15 @@ import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-c import type { TargetType } from '../../targets/target-adapter'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); + +function sanitizeTarget(target: unknown): TargetType { + if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) { + return target as TargetType; + } + return 'claude'; +} + /** * Check if API profile exists in config */ @@ -69,7 +78,7 @@ export function listApiProfiles(): ApiListResult { settingsPath: profile.settings || 'config.yaml', isConfigured: isApiProfileConfigured(name), configSource: 'unified', - target: profile.target || 'claude', + target: sanitizeTarget(profile.target), }); } // CLIProxy variants @@ -82,12 +91,12 @@ export function listApiProfiles(): ApiListResult { name, provider, settings: variant?.settings || '-', - target: variant?.target || 'claude', + target: sanitizeTarget(variant?.target), }); } } else { const config = loadConfigSafe(); - const legacyTargetMap = (config as { profile_targets?: Record }) + const legacyTargetMap = (config as { profile_targets?: Record }) .profile_targets; for (const [name, settingsPath] of Object.entries(config.profiles)) { // Skip 'default' profile - it's the user's native Claude settings @@ -99,18 +108,18 @@ export function listApiProfiles(): ApiListResult { settingsPath: settingsPath as string, isConfigured: isApiProfileConfigured(name), configSource: 'legacy', - target: legacyTargetMap?.[name] || 'claude', + target: sanitizeTarget(legacyTargetMap?.[name]), }); } // CLIProxy variants if (config.cliproxy) { for (const [name, v] of Object.entries(config.cliproxy)) { - const variant = v as { provider: string; settings: string; target?: TargetType }; + const variant = v as { provider: string; settings: string; target?: unknown }; variants.push({ name, provider: variant.provider, settings: variant.settings, - target: variant.target || 'claude', + target: sanitizeTarget(variant.target), }); } } diff --git a/src/targets/target-resolver.ts b/src/targets/target-resolver.ts index edc23481..b7a811f4 100644 --- a/src/targets/target-resolver.ts +++ b/src/targets/target-resolver.ts @@ -48,9 +48,13 @@ interface ParsedTargetFlags { cleanedArgs: string[]; } +function isValidTarget(target: unknown): target is TargetType { + return typeof target === 'string' && VALID_TARGETS.has(target as TargetType); +} + function normalizeTargetValue(value: string): TargetType { const normalized = value.toLowerCase(); - if (VALID_TARGETS.has(normalized)) { + if (isValidTarget(normalized)) { return normalized as TargetType; } @@ -120,8 +124,8 @@ export function resolveTargetType( } // 2. Check per-profile config - if (profileConfig?.target) { - return profileConfig.target; + if (profileConfig?.target !== undefined) { + return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude'; } // 3. Check argv[0] (busybox pattern) diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index a4082cdf..9841b9ce 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -37,6 +37,11 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'droid' })).toBe('droid'); }); + it('should fallback to claude when persisted profile target is invalid', () => { + process.argv = ['node', 'ccs']; + expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); + }); + it('should prioritize --target flag over profile config', () => { process.argv = ['node', 'ccs']; expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude'); From 2e8c7a36915e86e697b358db68aa029a200627da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:06 +0700 Subject: [PATCH 31/94] test(web-server): cover profile and variant target parsing - export route target parsers for direct unit coverage - verify accepted and rejected target payloads for both route modules --- src/web-server/routes/profile-routes.ts | 2 +- src/web-server/routes/variant-routes.ts | 2 +- .../web-server/target-parse-routes.test.ts | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/unit/web-server/target-parse-routes.test.ts diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 70aad36b..1b1a209f 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -18,7 +18,7 @@ import { updateSettingsFile } from './route-helpers'; const router = Router(); -function parseTarget(rawTarget: unknown): TargetType | null { +export function parseTarget(rawTarget: unknown): TargetType | null { if (rawTarget === undefined || rawTarget === null || rawTarget === '') { return null; } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index b6359c95..65a4c07b 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -24,7 +24,7 @@ import { const router = Router(); -function parseTarget(rawTarget: unknown): TargetType | null { +export function parseTarget(rawTarget: unknown): TargetType | null { if (rawTarget === undefined || rawTarget === null || rawTarget === '') { return null; } diff --git a/tests/unit/web-server/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts new file mode 100644 index 00000000..2852a749 --- /dev/null +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; + +import { parseTarget as parseProfileTarget } from '../../../src/web-server/routes/profile-routes'; +import { parseTarget as parseVariantTarget } from '../../../src/web-server/routes/variant-routes'; + +describe('route target parsing', () => { + it('accepts valid target values', () => { + expect(parseProfileTarget('claude')).toBe('claude'); + expect(parseProfileTarget('DROID')).toBe('droid'); + expect(parseVariantTarget(' claude ')).toBe('claude'); + expect(parseVariantTarget('droid')).toBe('droid'); + }); + + it('returns null for invalid target values', () => { + expect(parseProfileTarget('glm')).toBeNull(); + expect(parseProfileTarget('')).toBeNull(); + expect(parseVariantTarget('factory')).toBeNull(); + expect(parseVariantTarget(' ')).toBeNull(); + }); + + it('returns null for non-string values', () => { + expect(parseProfileTarget(undefined)).toBeNull(); + expect(parseProfileTarget(null)).toBeNull(); + expect(parseVariantTarget(123)).toBeNull(); + expect(parseVariantTarget({ target: 'claude' })).toBeNull(); + }); +}); From ac1c744239d57fb52441dde32cd5cb7f41e9f2b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:22 +0700 Subject: [PATCH 32/94] fix(cliproxy-sync): honor profile settings paths and targets - resolve settings file from profile.settingsPath instead of reconstructing by name - skip non-claude targets for local claude-api-key sync semantics - add mapper regression tests for both behaviors --- src/cliproxy/sync/profile-mapper.ts | 19 ++++- tests/unit/cliproxy/profile-mapper.test.ts | 96 ++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index d9b8b995..40583fa4 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; +import { expandPath } from '../../utils/helpers'; import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; import type { ClaudeKey } from '../management-api-types'; @@ -31,6 +32,15 @@ interface SettingsJson { env?: Record; } +function resolveProfileSettingsPath(settingsPath: string): string { + const normalized = settingsPath.replace(/\\/g, '/'); + if (normalized.startsWith('~/.ccs/')) { + return path.join(getCcsDir(), normalized.slice('~/.ccs/'.length)); + } + + return expandPath(settingsPath); +} + /** * Load syncable API profiles from CCS config. * Filters to only configured profiles (with real API keys). @@ -45,9 +55,14 @@ export function loadSyncableProfiles(): SyncableProfile[] { continue; } + // Local CLIProxy sync writes Claude-compatible entries only. + // Profiles pinned to non-claude targets are intentionally skipped. + if (profile.target !== 'claude') { + continue; + } + // Load settings.json for env vars - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`); + const settingsPath = resolveProfileSettingsPath(profile.settingsPath); let env: Record | undefined; try { diff --git a/tests/unit/cliproxy/profile-mapper.test.ts b/tests/unit/cliproxy/profile-mapper.test.ts index 55ccb4e6..c95f2c71 100644 --- a/tests/unit/cliproxy/profile-mapper.test.ts +++ b/tests/unit/cliproxy/profile-mapper.test.ts @@ -4,9 +4,11 @@ */ import * as assert from 'assert'; +const fs = require('fs'); describe('Profile Mapper', () => { const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper'); + const profileReader = require('../../../dist/api/services/profile-reader'); describe('mapProfileToClaudeKey', () => { it('returns null when env is missing', () => { @@ -86,6 +88,100 @@ describe('Profile Mapper', () => { const result = profileMapper.loadSyncableProfiles(); assert.ok(Array.isArray(result)); }); + + it('uses profile-provided settingsPath instead of reconstructing from profile name', () => { + const originalListApiProfiles = profileReader.listApiProfiles; + const originalExistsSync = fs.existsSync; + const originalReadFileSync = fs.readFileSync; + + const customSettingsPath = '/tmp/custom-sync-path.settings.json'; + const readPaths: string[] = []; + + try { + profileReader.listApiProfiles = () => ({ + profiles: [ + { + name: 'glm', + settingsPath: customSettingsPath, + isConfigured: true, + configSource: 'legacy', + target: 'claude', + }, + ], + variants: [], + }); + + fs.existsSync = (filePath: string) => filePath === customSettingsPath; + fs.readFileSync = (filePath: string) => { + readPaths.push(filePath); + return JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test-key', + }, + }); + }; + + const result = profileMapper.loadSyncableProfiles(); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].settingsPath, customSettingsPath); + assert.deepStrictEqual(readPaths, [customSettingsPath]); + } finally { + profileReader.listApiProfiles = originalListApiProfiles; + fs.existsSync = originalExistsSync; + fs.readFileSync = originalReadFileSync; + } + }); + + it('skips profiles pinned to non-claude targets during local sync mapping', () => { + const originalListApiProfiles = profileReader.listApiProfiles; + const originalExistsSync = fs.existsSync; + const originalReadFileSync = fs.readFileSync; + + const claudePath = '/tmp/claude-target.settings.json'; + const droidPath = '/tmp/droid-target.settings.json'; + const readPaths: string[] = []; + + try { + profileReader.listApiProfiles = () => ({ + profiles: [ + { + name: 'claude-profile', + settingsPath: claudePath, + isConfigured: true, + configSource: 'legacy', + target: 'claude', + }, + { + name: 'droid-profile', + settingsPath: droidPath, + isConfigured: true, + configSource: 'legacy', + target: 'droid', + }, + ], + variants: [], + }); + + fs.existsSync = (filePath: string) => filePath === claudePath || filePath === droidPath; + fs.readFileSync = (filePath: string) => { + readPaths.push(filePath); + return JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test-key', + }, + }); + }; + + const result = profileMapper.loadSyncableProfiles(); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'claude-profile'); + assert.deepStrictEqual(readPaths, [claudePath]); + } finally { + profileReader.listApiProfiles = originalListApiProfiles; + fs.existsSync = originalExistsSync; + fs.readFileSync = originalReadFileSync; + } + }); }); describe('generateSyncPayload', () => { From 8d95de9fd37cfbe8be9e83481cce07dedc418106 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:32 +0700 Subject: [PATCH 33/94] fix(cli): improve cliproxy target parsing edge cases - support POSIX -- terminator so dash-prefixed variant names remain positional - export variant parser for direct unit tests - add --target parsing coverage for API and cliproxy arg parsers --- src/commands/cliproxy/variant-subcommand.ts | 27 ++++++---- tests/unit/commands/api-command-args.test.ts | 30 +++++++++++ .../commands/cliproxy-variant-args.test.ts | 51 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 tests/unit/commands/cliproxy-variant-args.test.ts diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 53171b20..b3a00056 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -48,17 +48,24 @@ function parseTargetValue(rawValue: string): TargetType | null { return null; } -function parseProfileArgs(args: string[]): CliproxyProfileArgs { +export function parseProfileArgs(args: string[]): CliproxyProfileArgs { const result: CliproxyProfileArgs = { errors: [] }; + let parseOptions = true; + for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { + if (parseOptions && arg === '--') { + parseOptions = false; + continue; + } + + if (parseOptions && arg === '--provider' && args[i + 1]) { result.provider = args[++i] as CLIProxyProfileName; - } else if (arg === '--model' && args[i + 1]) { + } else if (parseOptions && arg === '--model' && args[i + 1]) { result.model = args[++i]; - } else if (arg === '--account' && args[i + 1]) { + } else if (parseOptions && arg === '--account' && args[i + 1]) { result.account = args[++i]; - } else if (arg === '--target') { + } else if (parseOptions && arg === '--target') { const rawValue = args[i + 1]; if (!rawValue || rawValue.startsWith('-')) { result.errors.push('Missing value for --target'); @@ -71,7 +78,7 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { result.target = parsedTarget; } } - } else if (arg.startsWith('--target=')) { + } else if (parseOptions && arg.startsWith('--target=')) { const rawValue = arg.slice('--target='.length); const parsedTarget = parseTargetValue(rawValue); if (!parsedTarget) { @@ -79,13 +86,13 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { } else { result.target = parsedTarget; } - } else if (arg === '--force') { + } else if (parseOptions && arg === '--force') { result.force = true; - } else if (arg === '--yes' || arg === '-y') { + } else if (parseOptions && (arg === '--yes' || arg === '-y')) { result.yes = true; - } else if (arg === '--composite') { + } else if (parseOptions && arg === '--composite') { result.composite = true; - } else if (!arg.startsWith('-') && !result.name) { + } else if ((!parseOptions || !arg.startsWith('-')) && !result.name) { result.name = arg; } } diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index fc5ac004..b73e1e08 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -44,10 +44,40 @@ describe('api-command arg parser', () => { expect(parsed.errors).toEqual([]); }); + test('parses --target=value for default profile target', () => { + const parsed = parseApiCommandArgs(['my-api', '--target=droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + test('validates invalid --target values', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); expect(parsed.target).toBeUndefined(); expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']); }); + + test('collects missing-value error for --target with no value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('treats empty --target=value as missing value', () => { + const parsed = parseApiCommandArgs(['my-api', '--target=']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('uses last --target value when repeated', () => { + const parsed = parseApiCommandArgs(['my-api', '--target', 'claude', '--target=droid']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); }); diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts new file mode 100644 index 00000000..1d3ad82e --- /dev/null +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand'; + +describe('cliproxy variant arg parser', () => { + test('parses --target value form', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'droid']); + + expect(parsed.name).toBe('variant-a'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('parses --target=value form', () => { + const parsed = parseProfileArgs(['variant-a', '--target=droid']); + + expect(parsed.name).toBe('variant-a'); + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('collects missing value error for --target with no value', () => { + const parsed = parseProfileArgs(['variant-a', '--target']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --target']); + }); + + test('uses last --target value when repeated', () => { + const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']); + + expect(parsed.target).toBe('droid'); + expect(parsed.errors).toEqual([]); + }); + + test('supports option terminator for variant names that start with dash', () => { + const parsed = parseProfileArgs(['--yes', '--', '-variant-a']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('-variant-a'); + expect(parsed.errors).toEqual([]); + }); + + test('does not parse flags after option terminator', () => { + const parsed = parseProfileArgs(['--', '--target', 'droid']); + + expect(parsed.target).toBeUndefined(); + expect(parsed.name).toBe('--target'); + expect(parsed.errors).toEqual([]); + }); +}); From b658b20709e4b8b1766442cbc4b9c944d34fc0bc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:41 +0700 Subject: [PATCH 34/94] fix(help): align cliproxy status and provider filter docs - replace incorrect root help command example for provider-filtered status - add parity regression test for cliproxy quota provider guidance --- src/commands/help-command.ts | 4 ++- .../unit/commands/help-command-parity.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 tests/unit/commands/help-command-parity.test.ts diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 38953277..4d028e97 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -353,7 +353,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', ''], // Spacer ['ccs cliproxy pause

', 'Pause account from rotation'], ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status [provider]', 'Show quota/tier/pause status'], + ['ccs cliproxy status', 'Show CLIProxy process status'], + ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], + ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], ]); // CLI Proxy configuration flags (new) diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts new file mode 100644 index 00000000..2aebfc8b --- /dev/null +++ b/tests/unit/commands/help-command-parity.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { handleHelpCommand } from '../../../src/commands/help-command'; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +describe('help command parity', () => { + const originalLog = console.log; + + afterEach(() => { + console.log = originalLog; + }); + + test('root help documents cliproxy provider filter under quota command', async () => { + const lines: string[] = []; + console.log = (...args: unknown[]) => { + lines.push(args.map((arg) => String(arg)).join(' ')); + }; + + await handleHelpCommand(); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false); + expect(rendered.includes('ccs cliproxy status')).toBe(true); + expect(rendered.includes('ccs cliproxy quota --provider ')).toBe(true); + }); +}); From d385bd19f2206db98571a184eba7960edb382afa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 17:01:53 +0700 Subject: [PATCH 35/94] fix(ui): harden target update flows in profile editors - send strict target-only payloads when cliproxy edit only changes target - route profile target updates through shared api client error handling - block concurrent save/target actions in header controls --- .../cliproxy/cliproxy-edit-dialog.tsx | 146 ++++++++++++++++-- .../profiles/editor/header-section.tsx | 20 ++- ui/src/components/profiles/editor/index.tsx | 44 +++--- 3 files changed, 169 insertions(+), 41 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 3f4c4f1b..82abaf5e 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -14,7 +14,7 @@ import { Label } from '@/components/ui/label'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; -import type { Variant } from '@/lib/api-client'; +import type { UpdateVariant, Variant } from '@/lib/api-client'; const singleProviderSchema = z.object({ provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), @@ -59,6 +59,63 @@ const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ label: getProviderDisplayName(id), })); +const COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; + +function normalizeOptionalValue(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function isSingleVariantOnlyTargetChange(variant: Variant, data: SingleProviderFormData): boolean { + const currentTarget = variant.target || 'claude'; + const currentModel = normalizeOptionalValue(variant.model); + const currentAccount = normalizeOptionalValue(variant.account); + const nextModel = normalizeOptionalValue(data.model); + const nextAccount = normalizeOptionalValue(data.account); + + return ( + data.target !== currentTarget && + data.provider === variant.provider && + nextModel === currentModel && + nextAccount === currentAccount + ); +} + +function normalizeCompositeTier(tier: { provider: string; model: string; account?: string }) { + return { + provider: tier.provider, + model: tier.model.trim(), + account: normalizeOptionalValue(tier.account), + }; +} + +function isCompositeVariantOnlyTargetChange(variant: Variant, data: CompositeFormData): boolean { + const currentTarget = variant.target || 'claude'; + const existingTiers = variant.tiers; + + if (!existingTiers || !variant.default_tier) { + return false; + } + + if (data.target === currentTarget) { + return false; + } + + if (data.default_tier !== variant.default_tier) { + return false; + } + + return COMPOSITE_TIERS.every((tier) => { + const current = normalizeCompositeTier(existingTiers[tier]); + const next = normalizeCompositeTier(data.tiers[tier]); + return ( + next.provider === current.provider && + next.model === current.model && + next.account === current.account + ); + }); +} + export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { const updateMutation = useUpdateVariant(); const isComposite = variant?.type === 'composite'; @@ -102,10 +159,40 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const onSubmitSingle = async (data: SingleProviderFormData) => { if (!variant) return; - // Filter out undefined values - backend interprets undefined as "no change" - const payload = Object.fromEntries( - Object.entries(data).filter(([, v]) => v !== undefined && v !== '') - ) as SingleProviderFormData; + + let payload: UpdateVariant = {}; + + if (isSingleVariantOnlyTargetChange(variant, data)) { + payload = { target: data.target }; + } else { + const currentTarget = variant.target || 'claude'; + const currentModel = normalizeOptionalValue(variant.model); + const currentAccount = normalizeOptionalValue(variant.account); + const nextModel = normalizeOptionalValue(data.model); + const nextAccount = normalizeOptionalValue(data.account); + + if (data.provider !== variant.provider) { + payload.provider = data.provider; + } + + if (nextModel !== currentModel) { + payload.model = nextModel; + } + + if (nextAccount !== currentAccount) { + payload.account = nextAccount; + } + + if (data.target !== currentTarget) { + payload.target = data.target; + } + } + + if (Object.keys(payload).length === 0) { + onOpenChange(false); + return; + } + try { await updateMutation.mutateAsync({ name: variant.name, data: payload }); onOpenChange(false); @@ -116,14 +203,53 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const onSubmitComposite = async (data: CompositeFormData) => { if (!variant) return; + + let payload: UpdateVariant = {}; + + if (isCompositeVariantOnlyTargetChange(variant, data)) { + payload = { target: data.target }; + } else { + const existingTiers = variant.tiers; + const normalizedTiers: NonNullable = { + opus: normalizeCompositeTier(data.tiers.opus), + sonnet: normalizeCompositeTier(data.tiers.sonnet), + haiku: normalizeCompositeTier(data.tiers.haiku), + }; + + const tiersChanged = !existingTiers + ? true + : COMPOSITE_TIERS.some((tier) => { + const current = normalizeCompositeTier(existingTiers[tier]); + const next = normalizedTiers[tier]; + return ( + next.provider !== current.provider || + next.model !== current.model || + next.account !== current.account + ); + }); + + if (variant.default_tier !== data.default_tier) { + payload.default_tier = data.default_tier; + } + + if ((variant.target || 'claude') !== data.target) { + payload.target = data.target; + } + + if (tiersChanged) { + payload.tiers = normalizedTiers; + } + } + + if (Object.keys(payload).length === 0) { + onOpenChange(false); + return; + } + try { await updateMutation.mutateAsync({ name: variant.name, - data: { - default_tier: data.default_tier, - target: data.target, - tiers: data.tiers, - }, + data: payload, }); onOpenChange(false); } catch (error) { diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx index 6692222a..637a93f3 100644 --- a/ui/src/components/profiles/editor/header-section.tsx +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -49,6 +49,9 @@ export function HeaderSection({ onDelete, onSave, }: HeaderSectionProps) { + const isMutating = isSaving || isTargetSaving; + const disableHeaderActions = isLoading || isMutating; + return (

@@ -68,8 +71,15 @@ export function HeaderSection({ )}
Default target: - { + if (disableHeaderActions) return; + onTargetChange(value as CliTarget); + }} + disabled={disableHeaderActions} + > + @@ -81,15 +91,15 @@ export function HeaderSection({
- {onDelete && ( - )} - + ))} + {filteredEntries.length} entries +
+ + {filteredEntries.length === 0 ? ( + + + No support entries match this filter. + + + ) : ( +
+ {filteredEntries.map((entry) => ( + + ))} +
+ )} +
+ + + + Maintainer Notes + + Keep update messaging in one place for future CLI expansions. + + + +

+ Edit{' '} + + ui/src/lib/support-updates-catalog.ts + {' '} + to add new notices or support entries. +

+

+ Home spotlight and this page consume the same catalog, so announcements stay consistent + without repeated UI edits. +

+
+
+ + ); +} From c7c4c87fb43c631564ba25900fef43e1768a1a06 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:05:35 +0700 Subject: [PATCH 38/94] feat(ui): surface updates center across dashboard - add /updates route and sidebar navigation entry - show updates spotlight on Home, API Profiles, and CLIProxy pages - export Updates page for lazy route loading consistency --- ui/src/App.tsx | 9 ++++ ui/src/components/layout/app-sidebar.tsx | 2 + .../components/updates/updates-spotlight.tsx | 51 +++++++++++++++++++ ui/src/pages/api.tsx | 5 ++ ui/src/pages/cliproxy.tsx | 2 + ui/src/pages/home.tsx | 3 ++ ui/src/pages/index.tsx | 2 + 7 files changed, 74 insertions(+) create mode 100644 ui/src/components/updates/updates-spotlight.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 66ff5a40..4a823ae4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -35,6 +35,7 @@ const SettingsPage = lazy(() => ); const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m.HealthPage }))); const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage }))); +const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage }))); // Loading fallback for lazy components function PageLoader() { @@ -68,6 +69,14 @@ export default function App() { } /> + }> + + + } + /> + + + {latest.title} + + {formatCatalogDate(latest.publishedAt)} + + + +

{latest.summary}

+ +
+ + Open Updates Center + + + + {!compact && primaryCommand && ( + + {primaryCommand} + + )} +
+
+ + ); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 1d56ac11..dc09dfbb 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -23,6 +23,7 @@ import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -96,6 +97,10 @@ export function ApiPage() { {/* OpenRouter Announcement Banner */} setCreateDialogOpen(true)} /> +
+ +
+ {/* Main Content */}
{/* Left Panel - Profiles List */} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index e75d645c..d79b3c76 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -17,6 +17,7 @@ import { AccountSafetyWarningCard } from '@/components/account/account-safety-wa import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useCliproxy, useCliproxyAuth, @@ -397,6 +398,7 @@ export function CliproxyPage() { {/* Right Panel */}
+ {showAccountSafetyWarning && } {selectedVariantData && parentAuthForVariant ? ( diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 2b192021..85a1cc19 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; +import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; @@ -165,6 +166,8 @@ export function HomePage() {
+ + {/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 03ba32b3..dda9ae12 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -15,3 +15,5 @@ export { SharedPage } from './shared'; export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; + +export { UpdatesPage } from './updates'; From 0a5b12b46bd587d0651524da4ea6eb5f8d787201 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:05:53 +0700 Subject: [PATCH 39/94] docs(readme): document dashboard updates center - add direct updates hub URL under dashboard quick start - include Updates Center in dashboard capability overview --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a51427c1..3d1fda3a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ ccs config # Opens http://localhost:3000 ``` +Dashboard updates hub: `http://localhost:3000/updates` + Want to run the dashboard in Docker? See `docker/README.md`. ### 3. Configure Your Accounts @@ -62,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles **Analytics Dashboard** From d47efc783ea606d6d9d4238d7b16bfadd0a1e25d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:14:14 +0700 Subject: [PATCH 40/94] fix(ui): align updates page with dashboard layout patterns - refactor updates route to full-height split layout like API/CLIProxy pages - move scrolling into internal panels via ScrollArea - keep announcements and matrix in consistent master-detail structure --- ui/src/pages/updates.tsx | 414 +++++++++++++++++++++++++-------------- 1 file changed, 270 insertions(+), 144 deletions(-) diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 24f7cd2b..06d2597e 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BellRing, Filter, Megaphone, Search } from 'lucide-react'; +import { BellRing, ChevronRight, Filter, Megaphone, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; import { SupportEntryCard } from '@/components/updates/support-entry-card'; import { SupportStatusBadge } from '@/components/updates/support-status-badge'; import { @@ -12,6 +13,7 @@ import { SUPPORT_NOTICES, SUPPORT_SCOPE_LABELS, formatCatalogDate, + type SupportNotice, type SupportScope, } from '@/lib/support-updates-catalog'; @@ -25,9 +27,77 @@ const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; +const CORE_CONTRACT = [ + { + id: 'base-url', + title: 'Base URL', + detail: 'Source endpoint is explicit per target/provider/profile.', + }, + { + id: 'auth', + title: 'Auth', + detail: 'OAuth or token ownership is visible in one support matrix.', + }, + { + id: 'model', + title: 'Model', + detail: 'Default model behavior stays configurable and documented.', + }, +] as const; + +function NoticeListItem({ + notice, + isSelected, + onSelect, +}: { + notice: SupportNotice; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + export function UpdatesPage() { const [scope, setScope] = useState('all'); const [query, setQuery] = useState(''); + const [selectedNoticeId, setSelectedNoticeId] = useState( + SUPPORT_NOTICES[0]?.id ?? null + ); + + const selectedNotice = useMemo( + () => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], + [selectedNoticeId] + ); const filteredEntries = useMemo(() => { const queryValue = query.trim().toLowerCase(); @@ -55,160 +125,216 @@ export function UpdatesPage() { }); }, [scope, query]); + const scopeStats = useMemo( + () => + SCOPE_FILTERS.filter((filter) => filter.id !== 'all').map((filter) => ({ + id: filter.id, + label: filter.label, + count: CLI_SUPPORT_ENTRIES.filter((entry) => entry.scope === filter.id).length, + })), + [] + ); + return ( -
- - - - - CCS Updates Center - - - Release visibility for runtime support, CLIProxy providers, and integration readiness. - - - - - - - This page is data-driven. Update one catalog file to publish new support notices - across dashboard surfaces. - - - -
- - ccsd glm - - - ccs codex --target droid "your prompt" - - - ccs cliproxy create mycodex --provider codex --target droid - +
+
+
+
+ +

Updates Center

- - - -
-
-

Announcements

- {SUPPORT_NOTICES.length} published -
- -
- {SUPPORT_NOTICES.map((notice) => ( - - -
-
- {notice.title} - {notice.summary} -
- -
-

- {formatCatalogDate(notice.publishedAt)} -

-
- -
    - {notice.highlights.map((highlight) => ( -
  • {highlight}
  • - ))} -
- -
- {notice.routes.map((route) => ( - - {route.label} - - ))} -
-
-
- ))} -
-
- -
-
-
-

Support Matrix

-

- Search by CLI/provider and filter by support surface. -

-
- -
- +

+ Release visibility for target, provider, and support rollouts. +

+
+ setQuery(event.target.value)} - placeholder="Search by command, provider, or note" - className="pl-8" + placeholder="Search support matrix" + className="pl-8 h-9" />
-
- - - Scope: - - {SCOPE_FILTERS.map((filter) => ( - - ))} - {filteredEntries.length} entries -
- - {filteredEntries.length === 0 ? ( - - - No support entries match this filter. - - - ) : ( -
- {filteredEntries.map((entry) => ( - + +
+ {SUPPORT_NOTICES.map((notice) => ( + setSelectedNoticeId(notice.id)} + /> ))}
- )} -
+ - - - Maintainer Notes - - Keep update messaging in one place for future CLI expansions. - - - -

- Edit{' '} - - ui/src/lib/support-updates-catalog.ts - {' '} - to add new notices or support entries. -

-

- Home spotlight and this page consume the same catalog, so announcements stay consistent - without repeated UI edits. -

-
-
+
+
+ + {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} + + + {CLI_SUPPORT_ENTRIES.length} support entr + {CLI_SUPPORT_ENTRIES.length !== 1 ? 'ies' : 'y'} + +
+
+
+ +
+ {selectedNotice && ( +
+
+
+

{selectedNotice.title}

+

{selectedNotice.summary}

+
+ +
+ +
+ + Published {formatCatalogDate(selectedNotice.publishedAt)} +
+ +
    + {selectedNotice.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+ +
+ {selectedNotice.routes.map((route) => ( + + {route.label} + + ))} +
+ +
+ {selectedNotice.commands.map((command) => ( + + {command} + + ))} +
+
+ )} + +
+
+ + + Support Matrix + + Filter by support area. Internal scroll keeps the page frame stable. + + + + +
+
+ + + Scope: + + {SCOPE_FILTERS.map((filter) => ( + + ))} + + {filteredEntries.length} match + +
+ +
+ + {filteredEntries.length === 0 ? ( +
+ No support entries match this filter. +
+ ) : ( +
+ {filteredEntries.map((entry) => ( + + ))} +
+ )} +
+
+
+
+
+ + + + Config Contract + + Every new CLI integration follows the same three configuration pillars. + + + + + +
+
+ {CORE_CONTRACT.map((item) => ( +
+

+ {item.title} +

+

{item.detail}

+
+ ))} +
+ +
+

+ Coverage by Scope +

+
+ {scopeStats.map((stat) => ( +
+ {stat.label} + + {stat.count} + +
+ ))} +
+
+ +
+ Update source of truth:{' '} + + ui/src/lib/support-updates-catalog.ts + +
+
+
+
+
+
+
+
); } From 473644564d4ef7785fe06bac3d6c6d5baef16ca3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:21:50 +0700 Subject: [PATCH 41/94] fix(ui): redesign updates page as changelog-first layout - shift /updates from support-matrix-heavy view to compact announcement workflow - reduce top section footprint and align structure with existing dashboard pages - enforce internal panel scrolling while keeping outer page frame fixed --- ui/src/pages/updates.tsx | 295 ++++++++++++++++++++------------------- 1 file changed, 150 insertions(+), 145 deletions(-) diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index 06d2597e..ffc85428 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,12 +1,12 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BellRing, ChevronRight, Filter, Megaphone, Search } from 'lucide-react'; +import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; -import { SupportEntryCard } from '@/components/updates/support-entry-card'; import { SupportStatusBadge } from '@/components/updates/support-status-badge'; import { CLI_SUPPORT_ENTRIES, @@ -27,24 +27,6 @@ const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; -const CORE_CONTRACT = [ - { - id: 'base-url', - title: 'Base URL', - detail: 'Source endpoint is explicit per target/provider/profile.', - }, - { - id: 'auth', - title: 'Auth', - detail: 'OAuth or token ownership is visible in one support matrix.', - }, - { - id: 'model', - title: 'Model', - detail: 'Default model behavior stays configurable and documented.', - }, -] as const; - function NoticeListItem({ notice, isSelected, @@ -59,29 +41,20 @@ function NoticeListItem({ type="button" onClick={onSelect} className={cn( - 'w-full rounded-lg border px-3 py-2.5 text-left transition-colors', + 'w-full rounded-lg border px-3 py-2 text-left transition-colors', isSelected ? 'border-primary/20 bg-primary/10' : 'border-transparent hover:border-border hover:bg-muted/70' )} >
-
+

{notice.title}

-

{notice.summary}

-
- - - {formatCatalogDate(notice.publishedAt)} - -
+

+ {formatCatalogDate(notice.publishedAt)} +

- +
); @@ -125,33 +98,23 @@ export function UpdatesPage() { }); }, [scope, query]); - const scopeStats = useMemo( - () => - SCOPE_FILTERS.filter((filter) => filter.id !== 'all').map((filter) => ({ - id: filter.id, - label: filter.label, - count: CLI_SUPPORT_ENTRIES.filter((entry) => entry.scope === filter.id).length, - })), - [] - ); - return ( -
-
+
+
-

Updates Center

+

Updates

- Release visibility for target, provider, and support rollouts. + Product announcements and release notes.

setQuery(event.target.value)} - placeholder="Search support matrix" + placeholder="Search updates or integrations" className="pl-8 h-9" />
@@ -176,67 +139,39 @@ export function UpdatesPage() { {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} - {CLI_SUPPORT_ENTRIES.length} support entr - {CLI_SUPPORT_ENTRIES.length !== 1 ? 'ies' : 'y'} + {filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''}
-
+
{selectedNotice && ( -
+
-

{selectedNotice.title}

-

{selectedNotice.summary}

+

{selectedNotice.title}

+

{selectedNotice.summary}

- -
- - Published {formatCatalogDate(selectedNotice.publishedAt)} -
- -
    - {selectedNotice.highlights.map((highlight) => ( -
  • - {highlight}
  • - ))} -
- -
- {selectedNotice.routes.map((route) => ( - - {route.label} - - ))} -
- -
- {selectedNotice.commands.map((command) => ( - - {command} - - ))} +
+ + Published {formatCatalogDate(selectedNotice.publishedAt)}
)} -
-
- +
+
+ - Support Matrix +
+ + Release Details +
- Filter by support area. Internal scroll keeps the page frame stable. + Changelog-first view with impacted integrations and quick commands.
@@ -245,7 +180,7 @@ export function UpdatesPage() {
- Scope: + Filter: {SCOPE_FILTERS.map((filter) => (
-
- - {filteredEntries.length === 0 ? ( -
- No support entries match this filter. -
- ) : ( -
- {filteredEntries.map((entry) => ( - + +
+
+

+ What Changed +

+
    + {selectedNotice?.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+
+ +
+

+ Dashboard Entry Points +

+
+ {selectedNotice?.routes.map((route) => ( + + {route.label} + ))}
- )} - -
+ + +
+

+ Quick Commands +

+
+ {selectedNotice?.commands.map((command) => ( + + {command} + + ))} +
+
+ +
+

+ Impacted Integrations +

+ {filteredEntries.length === 0 ? ( +
+ No integration entries match your current filter. +
+ ) : ( +
+ {filteredEntries.map((entry) => ( +
+
+
+

{entry.name}

+

{entry.summary}

+
+ +
+
+ + {SUPPORT_SCOPE_LABELS[entry.scope]} + + {entry.routes.map((route) => ( + + {route.label} + + ))} +
+ + {entry.commands[0]} + +
+ ))} +
+ )} +
+
+
- + - Config Contract - - Every new CLI integration follows the same three configuration pillars. - + Announcement Timeline + Recent notices in chronological order. - -
-
- {CORE_CONTRACT.map((item) => ( -
-

- {item.title} -

-

{item.detail}

+
+ {SUPPORT_NOTICES.map((notice) => ( +
+

{notice.summary}

+

+ {formatCatalogDate(notice.publishedAt)} +

+ + ))} -
-

- Coverage by Scope -

-
- {scopeStats.map((stat) => ( -
- {stat.label} - - {stat.count} - -
- ))} -
-
- -
+
Update source of truth:{' '} ui/src/lib/support-updates-catalog.ts From 1698eadc943fbeca97e32a5857f95507fe62106e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 18:27:28 +0700 Subject: [PATCH 42/94] fix(ui): remove nested sidebar list markup --- ui/src/components/layout/app-sidebar.tsx | 59 +++++++++++------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index c0a309b3..ed4631a4 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -172,38 +172,33 @@ export function AppSidebar() { defaultOpen={isParentActive(item.children) || isRouteActive(item.path)} className="group/collapsible" > - - {/* Click navigates to overview AND opens submenu */} - - navigate(item.path)} - > - {renderMenuIcon(item)} - - {getItemLabel(item)} - - - - - - - {item.children.map((child) => ( - - - - {child.label} - - - - ))} - - - + {/* Click navigates to overview AND opens submenu */} + + navigate(item.path)} + > + {renderMenuIcon(item)} + + {getItemLabel(item)} + + + + + + + {item.children.map((child) => ( + + + + {child.label} + + + + ))} + + ) : ( Date: Wed, 25 Feb 2026 20:06:02 +0700 Subject: [PATCH 43/94] feat(ui): redesign updates center into action inbox - replace release-note-heavy layout with task-first action inbox - add persistent local notice progress states (new/seen/done/dismissed) - introduce actionable notice metadata and related integration mapping - keep full-page shell fixed with internal scrolling only --- .../updates/notice-progress-badge.tsx | 50 ++ .../updates/updates-details-panel.tsx | 157 ++++++ .../components/updates/updates-inbox-item.tsx | 45 ++ .../updates/updates-notice-action-row.tsx | 38 ++ ui/src/lib/support-updates-catalog.ts | 75 +++ ui/src/lib/updates-notice-state.ts | 63 +++ ui/src/pages/updates.tsx | 448 ++++++------------ 7 files changed, 574 insertions(+), 302 deletions(-) create mode 100644 ui/src/components/updates/notice-progress-badge.tsx create mode 100644 ui/src/components/updates/updates-details-panel.tsx create mode 100644 ui/src/components/updates/updates-inbox-item.tsx create mode 100644 ui/src/components/updates/updates-notice-action-row.tsx create mode 100644 ui/src/lib/updates-notice-state.ts diff --git a/ui/src/components/updates/notice-progress-badge.tsx b/ui/src/components/updates/notice-progress-badge.tsx new file mode 100644 index 00000000..5b6c77ec --- /dev/null +++ b/ui/src/components/updates/notice-progress-badge.tsx @@ -0,0 +1,50 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +const NOTICE_PROGRESS_META: Record< + NoticeProgressState, + { label: string; className: string; showDot?: boolean } +> = { + new: { + label: 'Needs Action', + className: + 'border-amber-300/70 bg-amber-100/70 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300', + showDot: true, + }, + seen: { + label: 'In Review', + className: + 'border-blue-300/70 bg-blue-100/70 text-blue-800 dark:border-blue-500/40 dark:bg-blue-500/15 dark:text-blue-300', + }, + done: { + label: 'Done', + className: + 'border-emerald-300/70 bg-emerald-100/70 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300', + }, + dismissed: { + label: 'Dismissed', + className: + 'border-muted-foreground/20 bg-muted text-muted-foreground dark:border-muted-foreground/30', + }, +}; + +export function NoticeProgressBadge({ + state, + className, +}: { + state: NoticeProgressState; + className?: string; +}) { + const meta = NOTICE_PROGRESS_META[state]; + + return ( + + {meta.showDot && } + {meta.label} + + ); +} diff --git a/ui/src/components/updates/updates-details-panel.tsx b/ui/src/components/updates/updates-details-panel.tsx new file mode 100644 index 00000000..8a7c107f --- /dev/null +++ b/ui/src/components/updates/updates-details-panel.tsx @@ -0,0 +1,157 @@ +import { Link } from 'react-router-dom'; +import { CalendarClock, CheckCircle2, EyeOff, RotateCcw, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { CopyButton } from '@/components/ui/copy-button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { NoticeProgressBadge } from '@/components/updates/notice-progress-badge'; +import { UpdatesNoticeActionRow } from '@/components/updates/updates-notice-action-row'; +import { + SUPPORT_SCOPE_LABELS, + formatCatalogDate, + type CliSupportEntry, + type SupportNotice, +} from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; + +type UpdatableNoticeProgress = 'new' | 'seen' | 'done' | 'dismissed'; + +export function UpdatesDetailsPanel({ + notice, + progress, + relatedEntries, + onUpdateProgress, +}: { + notice: SupportNotice | null; + progress: NoticeProgressState | null; + relatedEntries: CliSupportEntry[]; + onUpdateProgress: (nextState: UpdatableNoticeProgress) => void; +}) { + if (!notice) { + return ( +
+

No updates available.

+
+ ); + } + + return ( +
+
+
+
+

{notice.title}

+

{notice.summary}

+
+
+ {progress && } + +
+
+ +
+ + Published {formatCatalogDate(notice.publishedAt)} +
+ +
+ + + +
+
+ +
+
+ + +
+ + Do Next +
+ {notice.primaryAction} +
+ + +
+ {notice.actions.map((action) => ( + + ))} +
+
+
+
+ +
+ + + Impacted Integrations + Related areas based on update scope and routing. + + + +
+ {relatedEntries.map((entry) => ( +
+
+

{entry.name}

+ + {SUPPORT_SCOPE_LABELS[entry.scope]} + +
+
+ {entry.routes[0] && ( + + )} + {entry.commands[0] && ( +
+ + {entry.commands[0]} + + +
+ )} +
+
+ ))} +
+
+
+
+ + + + Why It Matters + + Short context only, no wall-of-text release notes. + + + + +
    + {notice.highlights.map((highlight) => ( +
  • - {highlight}
  • + ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/updates/updates-inbox-item.tsx b/ui/src/components/updates/updates-inbox-item.tsx new file mode 100644 index 00000000..4b7ea984 --- /dev/null +++ b/ui/src/components/updates/updates-inbox-item.tsx @@ -0,0 +1,45 @@ +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { formatCatalogDate, type SupportNotice } from '@/lib/support-updates-catalog'; +import { type NoticeProgressState } from '@/lib/updates-notice-state'; +import { NoticeProgressBadge } from './notice-progress-badge'; + +export function UpdatesInboxItem({ + notice, + progress, + selected, + onSelect, +}: { + notice: SupportNotice; + progress: NoticeProgressState; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx new file mode 100644 index 00000000..5bbe1e37 --- /dev/null +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router-dom'; +import { ArrowUpRight, Terminal } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { CopyButton } from '@/components/ui/copy-button'; +import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; + +export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { + const isRouteAction = action.type === 'route' && action.path; + const isCommandAction = action.type === 'command' && action.command; + + return ( +
+
+
+

{action.label}

+

{action.description}

+
+ + {isRouteAction && ( + + )} +
+ + {isCommandAction && ( +
+ + {action.command} + +
+ )} +
+ ); +} diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index 195bc992..d0f2d86f 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -7,13 +7,26 @@ export interface SupportRouteHint { path: string; } +export interface SupportNoticeAction { + id: string; + label: string; + description: string; + type: 'route' | 'command'; + path?: string; + command?: string; +} + export interface SupportNotice { id: string; title: string; summary: string; + primaryAction: string; publishedAt: string; status: SupportStatus; + scopes: SupportScope[]; + entryIds: string[]; highlights: string[]; + actions: SupportNoticeAction[]; routes: SupportRouteHint[]; commands: string[]; } @@ -47,13 +60,48 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ title: 'Factory Droid support is live', summary: 'API Profiles and CLIProxy variants now support Droid as a first-class execution target.', + primaryAction: 'Set Droid as your default execution target for non-Claude workflows.', publishedAt: '2026-02-25', status: 'new', + scopes: ['target', 'api-profiles', 'cliproxy'], + entryIds: ['droid-target', 'custom-api-profiles', 'codex-cliproxy', 'agy-cliproxy'], highlights: [ 'Set default target to Droid when creating or editing API Profiles.', 'Set default target to Droid for CLIProxy variants, including Codex and Antigravity flows.', 'Use ccsd alias or --target droid for one-off target overrides.', ], + actions: [ + { + id: 'open-api-profiles', + label: 'Set default target in API Profiles', + description: + 'Open API Profiles and set Default Target to Droid for profiles you run often.', + type: 'route', + path: '/providers', + }, + { + id: 'open-cliproxy', + label: 'Set default target in CLIProxy variants', + description: + 'Open CLIProxy variants and set target to Droid for Codex/Antigravity or custom variants.', + type: 'route', + path: '/cliproxy', + }, + { + id: 'copy-ccsd-command', + label: 'Run once with Droid alias', + description: 'Use ccsd to force Droid target with your current profile.', + type: 'command', + command: 'ccsd glm', + }, + { + id: 'copy-target-override', + label: 'Run once with --target override', + description: 'Keep your default profile but force Droid for a single command.', + type: 'command', + command: 'ccs codex --target droid "your prompt"', + }, + ], routes: [ { label: 'API Profiles', path: '/providers' }, { label: 'CLIProxy', path: '/cliproxy' }, @@ -69,13 +117,32 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ title: 'Updates Center added to dashboard navigation', summary: 'CCS now has a dedicated updates route so support announcements are visible and reusable.', + primaryAction: 'Use this page as your action inbox, then close updates when done.', publishedAt: '2026-02-25', status: 'new', + scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'], + entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'], highlights: [ 'Single data source powers Home spotlight and Updates Center page.', 'New support entries can be added without touching multiple pages.', 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', ], + actions: [ + { + id: 'open-updates-page', + label: 'Review new support updates', + description: 'Work through pending notices and mark them done when configured.', + type: 'route', + path: '/updates', + }, + { + id: 'copy-open-dashboard', + label: 'Open dashboard from terminal', + description: 'Re-open config dashboard anytime from CLI.', + type: 'command', + command: 'ccs config', + }, + ], routes: [{ label: 'Updates Center', path: '/updates' }], commands: ['ccs config'], }, @@ -199,6 +266,14 @@ export const CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [ }, ]; +const SUPPORT_ENTRY_LOOKUP = new Map(CLI_SUPPORT_ENTRIES.map((entry) => [entry.id, entry])); + +export function getSupportEntriesForNotice(notice: SupportNotice): CliSupportEntry[] { + return notice.entryIds + .map((entryId) => SUPPORT_ENTRY_LOOKUP.get(entryId)) + .filter((entry): entry is CliSupportEntry => Boolean(entry)); +} + export function getLatestSupportNotice(): SupportNotice | null { if (SUPPORT_NOTICES.length === 0) { return null; diff --git a/ui/src/lib/updates-notice-state.ts b/ui/src/lib/updates-notice-state.ts new file mode 100644 index 00000000..66052f37 --- /dev/null +++ b/ui/src/lib/updates-notice-state.ts @@ -0,0 +1,63 @@ +import { type SupportNotice, type SupportStatus } from '@/lib/support-updates-catalog'; + +export type NoticeProgressState = 'new' | 'seen' | 'done' | 'dismissed'; + +export type NoticeProgressMap = Record; + +const NOTICE_PROGRESS_STORAGE_KEY = 'ccs:updates:notice-progress:v1'; + +export function getDefaultNoticeProgress(status: SupportStatus): NoticeProgressState { + return status === 'new' ? 'new' : 'seen'; +} + +export function getNoticeProgress( + notice: Pick, + progressMap: NoticeProgressMap +): NoticeProgressState { + return progressMap[notice.id] ?? getDefaultNoticeProgress(notice.status); +} + +export function isActionableNoticeState(progress: NoticeProgressState): boolean { + return progress !== 'done' && progress !== 'dismissed'; +} + +export function readNoticeProgressMap(): NoticeProgressMap { + if (typeof window === 'undefined') { + return {}; + } + + try { + const rawValue = window.localStorage.getItem(NOTICE_PROGRESS_STORAGE_KEY); + if (!rawValue) { + return {}; + } + + const parsed = JSON.parse(rawValue); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + const normalized: NoticeProgressMap = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof key !== 'string') { + continue; + } + + if (value === 'new' || value === 'seen' || value === 'done' || value === 'dismissed') { + normalized[key] = value; + } + } + + return normalized; + } catch { + return {}; + } +} + +export function writeNoticeProgressMap(progressMap: NoticeProgressMap): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(NOTICE_PROGRESS_STORAGE_KEY, JSON.stringify(progressMap)); +} diff --git a/ui/src/pages/updates.tsx b/ui/src/pages/updates.tsx index ffc85428..2ce8c9d1 100644 --- a/ui/src/pages/updates.tsx +++ b/ui/src/pages/updates.tsx @@ -1,345 +1,189 @@ -import { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { CalendarClock, ChevronRight, Filter, Megaphone, Search, Sparkles } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Megaphone, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; -import { SupportStatusBadge } from '@/components/updates/support-status-badge'; +import { UpdatesDetailsPanel } from '@/components/updates/updates-details-panel'; +import { UpdatesInboxItem } from '@/components/updates/updates-inbox-item'; import { - CLI_SUPPORT_ENTRIES, SUPPORT_NOTICES, - SUPPORT_SCOPE_LABELS, - formatCatalogDate, + getSupportEntriesForNotice, type SupportNotice, - type SupportScope, } from '@/lib/support-updates-catalog'; +import { + getNoticeProgress, + isActionableNoticeState, + readNoticeProgressMap, + writeNoticeProgressMap, + type NoticeProgressMap, +} from '@/lib/updates-notice-state'; -type ScopeFilter = 'all' | SupportScope; +type NoticeViewMode = 'inbox' | 'done' | 'all'; -const SCOPE_FILTERS: { id: ScopeFilter; label: string }[] = [ +const NOTICE_VIEW_MODES: { id: NoticeViewMode; label: string }[] = [ + { id: 'inbox', label: 'Action Required' }, + { id: 'done', label: 'Done' }, { id: 'all', label: 'All' }, - { id: 'target', label: SUPPORT_SCOPE_LABELS.target }, - { id: 'cliproxy', label: SUPPORT_SCOPE_LABELS.cliproxy }, - { id: 'api-profiles', label: SUPPORT_SCOPE_LABELS['api-profiles'] }, - { id: 'websearch', label: SUPPORT_SCOPE_LABELS.websearch }, ]; -function NoticeListItem({ - notice, - isSelected, - onSelect, -}: { - notice: SupportNotice; - isSelected: boolean; - onSelect: () => void; -}) { - return ( - - ); +function noticeMatchesQuery(notice: SupportNotice, queryValue: string): boolean { + if (!queryValue) { + return true; + } + + const haystack = [ + notice.title, + notice.summary, + notice.primaryAction, + ...notice.highlights, + ...notice.commands, + ...notice.actions.map( + (action) => `${action.label} ${action.description} ${action.command || ''}` + ), + ...notice.routes.map((route) => route.label), + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(queryValue); } export function UpdatesPage() { - const [scope, setScope] = useState('all'); + const notices = useMemo( + () => [...SUPPORT_NOTICES].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)), + [] + ); + const [viewMode, setViewMode] = useState('inbox'); const [query, setQuery] = useState(''); - const [selectedNoticeId, setSelectedNoticeId] = useState( - SUPPORT_NOTICES[0]?.id ?? null - ); + const [progressMap, setProgressMap] = useState(() => readNoticeProgressMap()); + const [selectedNoticeId, setSelectedNoticeId] = useState(null); - const selectedNotice = useMemo( - () => SUPPORT_NOTICES.find((notice) => notice.id === selectedNoticeId) ?? SUPPORT_NOTICES[0], - [selectedNoticeId] - ); + useEffect(() => { + writeNoticeProgressMap(progressMap); + }, [progressMap]); - const filteredEntries = useMemo(() => { + const visibleNotices = useMemo(() => { const queryValue = query.trim().toLowerCase(); - return CLI_SUPPORT_ENTRIES.filter((entry) => { - if (scope !== 'all' && entry.scope !== scope) { - return false; - } - - if (!queryValue) { - return true; - } - - const haystack = [ - entry.name, - entry.summary, - entry.notes || '', - ...entry.commands, - ...entry.routes.map((route) => route.label), - ] - .join(' ') - .toLowerCase(); - - return haystack.includes(queryValue); + return notices.filter((notice) => { + const progress = getNoticeProgress(notice, progressMap); + const matchesQuery = noticeMatchesQuery(notice, queryValue); + if (!matchesQuery) return false; + if (viewMode === 'done') return progress === 'done'; + if (viewMode === 'inbox') return isActionableNoticeState(progress); + return true; }); - }, [scope, query]); + }, [notices, progressMap, query, viewMode]); + + const selectedNotice = useMemo(() => { + const selectionPool = viewMode === 'all' ? notices : visibleNotices; + return ( + selectionPool.find((notice) => notice.id === selectedNoticeId) ?? selectionPool[0] ?? null + ); + }, [notices, selectedNoticeId, viewMode, visibleNotices]); + + const handleSelectNotice = (notice: SupportNotice) => { + setSelectedNoticeId(notice.id); + setProgressMap((previous) => { + const progress = getNoticeProgress(notice, previous); + if (progress !== 'new') { + return previous; + } + + return { ...previous, [notice.id]: 'seen' }; + }); + }; + + const pendingCount = useMemo( + () => + notices.filter((notice) => isActionableNoticeState(getNoticeProgress(notice, progressMap))) + .length, + [notices, progressMap] + ); + const doneCount = useMemo( + () => notices.filter((notice) => getNoticeProgress(notice, progressMap) === 'done').length, + [notices, progressMap] + ); return (
-
-
- -

Updates

+
+
+
+ +

Updates Inbox

+
+

+ Focus on actions, then mark updates done or dismissed. +

-

- Product announcements and release notes. -

+ +
+
+

Needs Action

+

{pendingCount}

+
+
+

Done

+

{doneCount}

+
+
+
setQuery(event.target.value)} - placeholder="Search updates or integrations" - className="pl-8 h-9" + placeholder="Search actions or commands" + className="h-9 pl-8" />
+ +
+ {NOTICE_VIEW_MODES.map((mode) => ( + + ))} +
-
- {SUPPORT_NOTICES.map((notice) => ( - setSelectedNoticeId(notice.id)} - /> - ))} +
+ {visibleNotices.length === 0 ? ( +
+ No notices match this view. +
+ ) : ( + visibleNotices.map((notice) => ( + handleSelectNotice(notice)} + /> + )) + )}
- -
-
- - {SUPPORT_NOTICES.length} notice{SUPPORT_NOTICES.length !== 1 ? 's' : ''} - - - {filteredEntries.length} result{filteredEntries.length !== 1 ? 's' : ''} - -
-
-
- {selectedNotice && ( -
-
-
-

{selectedNotice.title}

-

{selectedNotice.summary}

-
- -
-
- - Published {formatCatalogDate(selectedNotice.publishedAt)} -
-
- )} - -
-
- - -
- - Release Details -
- - Changelog-first view with impacted integrations and quick commands. - -
- - -
-
- - - Filter: - - {SCOPE_FILTERS.map((filter) => ( - - ))} - - {filteredEntries.length} integration{filteredEntries.length !== 1 ? 's' : ''} - -
- - -
-
-

- What Changed -

-
    - {selectedNotice?.highlights.map((highlight) => ( -
  • - {highlight}
  • - ))} -
-
- -
-

- Dashboard Entry Points -

-
- {selectedNotice?.routes.map((route) => ( - - {route.label} - - ))} -
-
- -
-

- Quick Commands -

-
- {selectedNotice?.commands.map((command) => ( - - {command} - - ))} -
-
- -
-

- Impacted Integrations -

- {filteredEntries.length === 0 ? ( -
- No integration entries match your current filter. -
- ) : ( -
- {filteredEntries.map((entry) => ( -
-
-
-

{entry.name}

-

{entry.summary}

-
- -
-
- - {SUPPORT_SCOPE_LABELS[entry.scope]} - - {entry.routes.map((route) => ( - - {route.label} - - ))} -
- - {entry.commands[0]} - -
- ))} -
- )} -
-
-
-
-
-
- - - - Announcement Timeline - Recent notices in chronological order. - - - -
- {SUPPORT_NOTICES.map((notice) => ( - - ))} - -
- Update source of truth:{' '} - - ui/src/lib/support-updates-catalog.ts - -
-
-
-
-
-
-
-
+ { + if (!selectedNotice) return; + setProgressMap((previous) => ({ ...previous, [selectedNotice.id]: nextState })); + }} + />
); } From ae7ab59746172419eba5b7a5ab9773e7e0c199a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 20:07:05 +0700 Subject: [PATCH 44/94] fix(ui): tighten updates action row type narrowing --- ui/src/components/updates/updates-notice-action-row.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/src/components/updates/updates-notice-action-row.tsx b/ui/src/components/updates/updates-notice-action-row.tsx index 5bbe1e37..0ac130f0 100644 --- a/ui/src/components/updates/updates-notice-action-row.tsx +++ b/ui/src/components/updates/updates-notice-action-row.tsx @@ -5,9 +5,6 @@ import { CopyButton } from '@/components/ui/copy-button'; import { type SupportNoticeAction } from '@/lib/support-updates-catalog'; export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction }) { - const isRouteAction = action.type === 'route' && action.path; - const isCommandAction = action.type === 'command' && action.command; - return (
@@ -16,7 +13,7 @@ export function UpdatesNoticeActionRow({ action }: { action: SupportNoticeAction

{action.description}

- {isRouteAction && ( + {action.type === 'route' && action.path && (
- {isCommandAction && ( + {action.type === 'command' && action.command && (
{action.command} From 69378ada3e779e8e731b4681486648a166482beb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 20:14:41 +0700 Subject: [PATCH 45/94] fix(ui): de-surface updates center from primary navigation - remove Updates Center from sidebar general nav - remove updates spotlight banners from home, api, and cliproxy pages - adjust updates catalog copy to reflect on-demand inbox usage --- ui/src/components/layout/app-sidebar.tsx | 2 -- ui/src/lib/support-updates-catalog.ts | 19 ++++++++++--------- ui/src/pages/api.tsx | 5 ----- ui/src/pages/cliproxy.tsx | 2 -- ui/src/pages/home.tsx | 3 --- 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index ed4631a4..47fcc1fa 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -11,7 +11,6 @@ import { BarChart3, Gauge, Github, - Megaphone, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { @@ -68,7 +67,6 @@ const navGroups: SidebarGroupDef[] = [ title: 'General', items: [ { path: '/', icon: Home, label: 'Home' }, - { path: '/updates', icon: Megaphone, label: 'Updates Center' }, { path: '/analytics', icon: BarChart3, label: 'Analytics' }, ], }, diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts index d0f2d86f..aa9cee80 100644 --- a/ui/src/lib/support-updates-catalog.ts +++ b/ui/src/lib/support-updates-catalog.ts @@ -114,24 +114,25 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ }, { id: 'updates-center-launch', - title: 'Updates Center added to dashboard navigation', + title: 'Updates inbox is available for rollout tasks', summary: - 'CCS now has a dedicated updates route so support announcements are visible and reusable.', - primaryAction: 'Use this page as your action inbox, then close updates when done.', + 'A focused updates inbox exists for setup tasks and rollout guidance when you need it.', + primaryAction: + 'Use this page only when needed for rollout tasks, then return to your normal workflow.', publishedAt: '2026-02-25', status: 'new', scopes: ['target', 'cliproxy', 'api-profiles', 'websearch'], entryIds: ['droid-target', 'codex-cliproxy', 'custom-api-profiles', 'opencode-websearch'], highlights: [ - 'Single data source powers Home spotlight and Updates Center page.', - 'New support entries can be added without touching multiple pages.', - 'Catalog includes targets, CLIProxy providers, and WebSearch integrations.', + 'Single data source powers update content and integration mapping.', + 'Notices can be tracked as new, seen, done, or dismissed.', + 'Catalog covers target CLI, CLIProxy providers, and WebSearch integrations.', ], actions: [ { id: 'open-updates-page', - label: 'Review new support updates', - description: 'Work through pending notices and mark them done when configured.', + label: 'Open updates inbox when needed', + description: 'Review rollout tasks only when you want guided setup changes.', type: 'route', path: '/updates', }, @@ -143,7 +144,7 @@ export const SUPPORT_NOTICES: SupportNotice[] = [ command: 'ccs config', }, ], - routes: [{ label: 'Updates Center', path: '/updates' }], + routes: [{ label: 'Updates Inbox', path: '/updates' }], commands: ['ccs config'], }, ]; diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index dc09dfbb..1d56ac11 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -23,7 +23,6 @@ import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; @@ -97,10 +96,6 @@ export function ApiPage() { {/* OpenRouter Announcement Banner */} setCreateDialogOpen(true)} /> -
- -
- {/* Main Content */}
{/* Left Panel - Profiles List */} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index d79b3c76..e75d645c 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -17,7 +17,6 @@ import { AccountSafetyWarningCard } from '@/components/account/account-safety-wa import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { useCliproxy, useCliproxyAuth, @@ -398,7 +397,6 @@ export function CliproxyPage() { {/* Right Panel */}
- {showAccountSafetyWarning && } {selectedVariantData && parentAuthForVariant ? ( diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 85a1cc19..2b192021 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -7,7 +7,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; -import { UpdatesSpotlight } from '@/components/updates/updates-spotlight'; import { cn } from '@/lib/utils'; import type { LucideIcon } from 'lucide-react'; @@ -166,8 +165,6 @@ export function HomePage() {
- - {/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( From e67ca539b28ee51df0e95dda5c21608ed0592d02 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 13:21:40 +0000 Subject: [PATCH 46/94] chore(release): 7.50.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 855675b7..bd760d42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0", + "version": "7.50.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 60b1c043c7e721ca576f9b4f4188468a3ddea846 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 20:58:35 +0700 Subject: [PATCH 47/94] feat(droid): align BYOK provider mapping for ccsd - add Droid provider resolver aligned with Factory BYOK schema - persist and normalize CCS_DROID_PROVIDER across CLI and dashboard flows - use Droid managed selector aliases with safe legacy fallback - add unit coverage for provider inference and selector usage Closes #632 --- README.md | 5 + src/api/services/profile-writer.ts | 26 ++- src/ccs.ts | 17 +- src/targets/droid-adapter.ts | 17 +- src/targets/droid-config-manager.ts | 42 ++++- src/targets/droid-provider.ts | 148 ++++++++++++++++++ src/targets/index.ts | 2 + src/web-server/routes/profile-routes.ts | 33 +++- src/web-server/routes/route-helpers.ts | 26 ++- .../unit/targets/droid-config-manager.test.ts | 15 ++ tests/unit/targets/droid-provider.test.ts | 80 ++++++++++ tests/unit/targets/target-registry.test.ts | 26 ++- 12 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 src/targets/droid-provider.ts create mode 100644 tests/unit/targets/droid-provider.test.ts diff --git a/README.md b/README.md index 3d1fda3a..5aff1eb9 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,11 @@ ccsd glm Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`). +For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint +(`CCS_DROID_PROVIDER`) using one of: +`anthropic`, `openai`, or `generic-chat-completion-api`. +If the hint is missing, CCS resolves provider from base URL/model at runtime. + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index f90455c2..6e21c2e4 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -13,6 +13,7 @@ import { } from '../../config/unified-config-loader'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; +import { resolveDroidProvider } from '../../targets/droid-provider'; import type { ModelMapping, CreateApiProfileResult, @@ -30,10 +31,16 @@ function createSettingsFile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping + models: ModelMapping, + provider?: string ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model: models.default, + }); const settings = { env: { @@ -43,6 +50,7 @@ function createSettingsFile( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + CCS_DROID_PROVIDER: droidProvider, // OpenRouter requires explicitly blanking the API key to prevent conflicts ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, @@ -97,11 +105,17 @@ function createApiProfileUnified( baseUrl: string, apiKey: string, models: ModelMapping, - target: TargetType = 'claude' + target: TargetType = 'claude', + provider?: string ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; const settingsPath = path.join(ccsDir, settingsFile); + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model: models.default, + }); const settings = { env: { @@ -111,6 +125,7 @@ function createApiProfileUnified( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + CCS_DROID_PROVIDER: droidProvider, // OpenRouter requires explicitly blanking the API key to prevent conflicts ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, @@ -140,15 +155,16 @@ export function createApiProfile( baseUrl: string, apiKey: string, models: ModelMapping, - target: TargetType = 'claude' + target: TargetType = 'claude', + provider?: string ): CreateApiProfileResult { try { const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models, target); + createApiProfileUnified(name, baseUrl, apiKey, models, target, provider); } else { - createSettingsFile(name, baseUrl, apiKey, models); + createSettingsFile(name, baseUrl, apiKey, models, provider); updateLegacyConfig(name, target); } diff --git a/src/ccs.ts b/src/ccs.ts index 94e096e6..245d4bf9 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -54,6 +54,7 @@ import { ClaudeAdapter, DroidAdapter, pruneOrphanedModels, + resolveDroidProvider, type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; @@ -841,7 +842,11 @@ async function main(): Promise { baseUrl: envVars['ANTHROPIC_BASE_URL'] || '', apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '', model: envVars['ANTHROPIC_MODEL'] || undefined, - provider: 'anthropic', + provider: resolveDroidProvider({ + provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'], + baseUrl: envVars['ANTHROPIC_BASE_URL'], + model: envVars['ANTHROPIC_MODEL'], + }), envVars, }; @@ -1010,6 +1015,11 @@ async function main(): Promise { baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '', model: settingsEnv['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], + baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], + model: settingsEnv['ANTHROPIC_MODEL'], + }), }; await adapter.prepareCredentials(creds); const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); @@ -1076,6 +1086,11 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'] || '', apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '', model: process.env['ANTHROPIC_MODEL'], + provider: resolveDroidProvider({ + provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'], + baseUrl: process.env['ANTHROPIC_BASE_URL'], + model: process.env['ANTHROPIC_MODEL'], + }), }; if (!creds.baseUrl || !creds.apiKey) { console.error( diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 1a89ca20..10141469 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -2,7 +2,7 @@ * Droid Adapter * * TargetAdapter implementation for Factory Droid CLI. - * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-`. + * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:`. */ import { spawn, ChildProcess } from 'child_process'; @@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from ' import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector'; import type { ProfileType } from '../types/profile'; import { upsertCcsModel } from './droid-config-manager'; +import { resolveDroidProvider } from './droid-provider'; import { escapeShellArg } from '../utils/shell-executor'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -18,6 +19,7 @@ import { runCleanup } from '../errors'; export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; readonly displayName = 'Factory Droid'; + private readonly modelSelectorsByProfile = new Map(); private validateCredentials(creds: TargetCredentials): void { if (!creds.baseUrl?.trim()) { @@ -43,13 +45,19 @@ export class DroidAdapter implements TargetAdapter { */ async prepareCredentials(creds: TargetCredentials): Promise { this.validateCredentials(creds); - await upsertCcsModel(creds.profile, { + const provider = resolveDroidProvider({ + provider: creds.provider, + baseUrl: creds.baseUrl, + model: creds.model, + }); + const modelRef = await upsertCcsModel(creds.profile, { model: creds.model || 'claude-opus-4-6', displayName: `CCS ${creds.profile}`, baseUrl: creds.baseUrl, apiKey: creds.apiKey, - provider: creds.provider || 'anthropic', + provider, }); + this.modelSelectorsByProfile.set(creds.profile, modelRef.selector); } buildArgs(profile: string, userArgs: string[]): string[] { @@ -58,7 +66,8 @@ export class DroidAdapter implements TargetAdapter { `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } - return ['-m', `custom:ccs-${profile}`, ...userArgs]; + const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`; + return ['-m', selector, ...userArgs]; } /** diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 8e61eec4..1b48f1af 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -43,6 +43,14 @@ export interface DroidCustomModel { maxOutputTokens?: number; } +export interface DroidManagedModelRef { + profile: string; + displayName: string; + index: number; + selectorAlias: string; + selector: string; +} + interface DroidSettings { customModels?: DroidCustomModelEntry[]; [key: string]: unknown; @@ -97,6 +105,11 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null { return isDroidCustomModelEntry(value) ? value : null; } +function buildSelectorAlias(displayName: string, index: number): string { + const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-'); + return `${normalizedDisplayName}-${index}`; +} + function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] { if (Array.isArray(value)) { return value @@ -302,11 +315,15 @@ function writeDroidSettings(settings: DroidSettings): void { * Upsert a CCS-managed custom model entry. * Acquires file lock to prevent concurrent write races. */ -export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise { +export async function upsertCcsModel( + profile: string, + model: DroidCustomModel +): Promise { validateProfileName(profile); ensureFactoryDir(); let release: (() => Promise) | undefined; + let ref: DroidManagedModelRef | null = null; try { release = await acquireFactoryLock(10); @@ -330,9 +347,32 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel): } writeDroidSettings(settings); + + const index = settings.customModels.findIndex( + (entry) => parseManagedProfile(entry.displayName) === profile + ); + const safeIndex = index >= 0 ? index : 0; + const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex); + ref = { + profile, + displayName: entry.displayName, + index: safeIndex, + selectorAlias, + selector: `custom:${selectorAlias}`, + }; } finally { if (release) await release(); } + + return ( + ref || { + profile, + displayName: `CCS ${profile}`, + index: 0, + selectorAlias: `CCS-${profile}-0`, + selector: `custom:CCS-${profile}-0`, + } + ); } /** diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts new file mode 100644 index 00000000..3e6c4047 --- /dev/null +++ b/src/targets/droid-provider.ts @@ -0,0 +1,148 @@ +/** + * Droid BYOK provider resolution helpers. + * + * Factory BYOK accepts exactly: + * - anthropic + * - openai + * - generic-chat-completion-api + * + * CCS stores provider hints in profile settings as CCS_DROID_PROVIDER and + * resolves a best-effort provider from base URL/model when the hint is absent. + */ + +export type DroidProvider = 'anthropic' | 'openai' | 'generic-chat-completion-api'; + +const GENERIC_PROVIDER_ALIASES = new Set([ + 'generic', + 'generic-openai', + 'generic-openai-api', + 'generic-chat', + 'generic-chat-completions', + 'openai-compatible', + 'chat-completions', +]); + +const OPENAI_PROVIDER_ALIASES = new Set(['openai-responses', 'openai-official']); +const ANTHROPIC_PROVIDER_ALIASES = new Set(['anthropic-compatible']); + +/** + * Normalize potentially messy provider input into a valid Factory provider. + */ +export function normalizeDroidProvider(provider: string | undefined | null): DroidProvider | null { + if (!provider) return null; + const normalized = provider.trim().toLowerCase(); + if (!normalized) return null; + + if (normalized === 'anthropic' || ANTHROPIC_PROVIDER_ALIASES.has(normalized)) { + return 'anthropic'; + } + if (normalized === 'openai' || OPENAI_PROVIDER_ALIASES.has(normalized)) { + return 'openai'; + } + if (normalized === 'generic-chat-completion-api' || GENERIC_PROVIDER_ALIASES.has(normalized)) { + return 'generic-chat-completion-api'; + } + + return null; +} + +/** + * Infer provider primarily from base URL patterns used in BYOK configs. + */ +export function inferDroidProviderFromBaseUrl( + baseUrl: string | undefined | null +): DroidProvider | null { + if (!baseUrl) return null; + const raw = baseUrl.trim(); + if (!raw) return null; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + + const host = parsed.host.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + + if ( + host.includes('api.openai.com') || + host.includes('.openai.azure.com') || + host.includes('.services.ai.azure.com') + ) { + return 'openai'; + } + + if (host.includes('anthropic.com') || pathname.includes('/anthropic')) { + return 'anthropic'; + } + + if ( + host.includes('openrouter.ai') || + host.includes('api.groq.com') || + host.includes('api.deepinfra.com') || + host.includes('api.fireworks.ai') || + host.includes('inference.baseten.co') || + host.includes('huggingface.co') || + host.includes('ollama.com') || + pathname.includes('/openai') || + pathname.includes('/chat/completions') + ) { + return 'generic-chat-completion-api'; + } + + return null; +} + +/** + * Infer provider from model naming when URL does not provide a clear signal. + */ +export function inferDroidProviderFromModel( + model: string | undefined | null +): DroidProvider | null { + if (!model) return null; + const normalized = model.trim().toLowerCase(); + if (!normalized) return null; + + if (normalized.startsWith('claude-')) { + return 'anthropic'; + } + if ( + normalized.startsWith('gpt-') || + normalized.startsWith('o1') || + normalized.startsWith('o3') || + normalized.startsWith('o4') + ) { + return 'openai'; + } + return null; +} + +export interface DroidProviderResolveInput { + provider?: string | null; + baseUrl?: string | null; + model?: string | null; +} + +/** + * Resolve a provider for Droid custom model entries. + * + * Precedence: + * 1) explicit provider hint (CCS_DROID_PROVIDER) + * 2) base URL inference + * 3) model inference + * 4) anthropic (backward-compatible default for legacy CCS profiles) + */ +export function resolveDroidProvider(input: DroidProviderResolveInput): DroidProvider { + const explicit = normalizeDroidProvider(input.provider); + if (explicit) return explicit; + + const fromUrl = inferDroidProviderFromBaseUrl(input.baseUrl); + if (fromUrl) return fromUrl; + + const fromModel = inferDroidProviderFromModel(input.model); + if (fromModel) return fromModel; + + return 'anthropic'; +} diff --git a/src/targets/index.ts b/src/targets/index.ts index a0c42429..2d05d240 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -27,4 +27,6 @@ export { pruneOrphanedModels, } from './droid-config-manager'; export type { DroidCustomModel } from './droid-config-manager'; +export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider'; +export type { DroidProvider } from './droid-provider'; export { resolveTargetType, stripTargetFlag } from './target-resolver'; diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 1b1a209f..eb176802 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -14,6 +14,7 @@ import { } from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; import type { TargetType } from '../../targets/target-adapter'; +import { normalizeDroidProvider } from '../../targets/droid-provider'; import { updateSettingsFile } from './route-helpers'; const router = Router(); @@ -61,12 +62,20 @@ router.get('/', (_req: Request, res: Response): void => { */ router.post('/', (req: Request, res: Response): void => { const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const providerHint = req.body?.droidProvider ?? req.body?.provider; + const parsedProvider = normalizeDroidProvider(providerHint); const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); return; } + if (providerHint !== undefined && parsedProvider === null) { + res.status(400).json({ + error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api', + }); + return; + } if (!name || !baseUrl || !apiKey) { res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); @@ -99,7 +108,8 @@ router.post('/', (req: Request, res: Response): void => { sonnet: sonnetModel || model || '', haiku: haikuModel || model || '', }, - parsedTarget || 'claude' + parsedTarget || 'claude', + parsedProvider || undefined ); if (!result.success) { @@ -120,12 +130,20 @@ router.post('/', (req: Request, res: Response): void => { router.put('/:name', (req: Request, res: Response): void => { const { name } = req.params; const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const providerHint = req.body?.droidProvider ?? req.body?.provider; + const parsedProvider = normalizeDroidProvider(providerHint); const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); return; } + if (providerHint !== undefined && parsedProvider === null) { + res.status(400).json({ + error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api', + }); + return; + } // Check if profile exists (uses unified config when available) if (!apiProfileExists(name)) { @@ -150,7 +168,8 @@ router.put('/:name', (req: Request, res: Response): void => { model !== undefined || opusModel !== undefined || sonnetModel !== undefined || - haikuModel !== undefined; + haikuModel !== undefined || + providerHint !== undefined; const hasTargetUpdate = target !== undefined; if (!hasSettingsUpdates && !hasTargetUpdate) { @@ -159,7 +178,15 @@ router.put('/:name', (req: Request, res: Response): void => { } if (hasSettingsUpdates) { - updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); + updateSettingsFile(name, { + baseUrl, + apiKey, + model, + opusModel, + sonnetModel, + haikuModel, + provider: parsedProvider || undefined, + }); } if (hasTargetUpdate && parsedTarget) { diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 076a12d7..24b54245 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { resolveDroidProvider } from '../../targets/droid-provider'; import type { Config, Settings } from '../../types/config'; /** Model mapping for API profiles */ @@ -64,10 +65,16 @@ export function createSettingsFile( name: string, baseUrl: string, apiKey: string, - models: ModelMapping = {} + models: ModelMapping = {}, + provider?: string ): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); const { model, opusModel, sonnetModel, haikuModel } = models; + const droidProvider = resolveDroidProvider({ + provider, + baseUrl, + model, + }); const settings: Settings = { env: { @@ -77,6 +84,7 @@ export function createSettingsFile( ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), + CCS_DROID_PROVIDER: droidProvider, }, }; @@ -96,6 +104,7 @@ export function updateSettingsFile( opusModel?: string; sonnetModel?: string; haikuModel?: string; + provider?: string; } ): void { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); @@ -153,6 +162,21 @@ export function updateSettingsFile( } } + if ( + updates.provider !== undefined || + updates.baseUrl !== undefined || + updates.model !== undefined || + settings.env?.CCS_DROID_PROVIDER + ) { + settings.env = settings.env || {}; + const resolvedProvider = resolveDroidProvider({ + provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, + baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, + model: updates.model ?? settings.env.ANTHROPIC_MODEL, + }); + settings.env.CCS_DROID_PROVIDER = resolvedProvider; + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index dd1c4675..7b1f30b4 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -32,6 +32,21 @@ describe('droid-config-manager', () => { }); describe('upsertCcsModel', () => { + it('should return a selector reference for the managed model', async () => { + const ref = await upsertCcsModel('gemini', { + model: 'claude-opus-4-6', + displayName: 'CCS gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + provider: 'anthropic', + }); + + expect(ref.profile).toBe('gemini'); + expect(ref.selectorAlias).toBe('CCS-gemini-0'); + expect(ref.selector).toBe('custom:CCS-gemini-0'); + expect(ref.index).toBe(0); + }); + it('should create settings.json with customModels', async () => { await upsertCcsModel('gemini', { model: 'claude-opus-4-6', diff --git a/tests/unit/targets/droid-provider.test.ts b/tests/unit/targets/droid-provider.test.ts new file mode 100644 index 00000000..109764f0 --- /dev/null +++ b/tests/unit/targets/droid-provider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { + normalizeDroidProvider, + inferDroidProviderFromBaseUrl, + inferDroidProviderFromModel, + resolveDroidProvider, +} from '../../../src/targets/droid-provider'; + +describe('droid-provider', () => { + describe('normalizeDroidProvider', () => { + it('accepts canonical provider names', () => { + expect(normalizeDroidProvider('anthropic')).toBe('anthropic'); + expect(normalizeDroidProvider('openai')).toBe('openai'); + expect(normalizeDroidProvider('generic-chat-completion-api')).toBe( + 'generic-chat-completion-api' + ); + }); + + it('normalizes compatibility aliases', () => { + expect(normalizeDroidProvider('anthropic-compatible')).toBe('anthropic'); + expect(normalizeDroidProvider('openai-compatible')).toBe('generic-chat-completion-api'); + }); + + it('returns null for unknown values', () => { + expect(normalizeDroidProvider('')).toBeNull(); + expect(normalizeDroidProvider('unsupported')).toBeNull(); + expect(normalizeDroidProvider(undefined)).toBeNull(); + }); + }); + + describe('inferDroidProviderFromBaseUrl', () => { + it('detects anthropic-compatible endpoints', () => { + expect(inferDroidProviderFromBaseUrl('https://api.anthropic.com')).toBe('anthropic'); + expect(inferDroidProviderFromBaseUrl('https://api.z.ai/api/anthropic')).toBe('anthropic'); + }); + + it('detects openai official endpoints', () => { + expect(inferDroidProviderFromBaseUrl('https://api.openai.com/v1')).toBe('openai'); + }); + + it('detects generic openai-chat-compatible endpoints', () => { + expect(inferDroidProviderFromBaseUrl('https://openrouter.ai/api/v1')).toBe( + 'generic-chat-completion-api' + ); + expect(inferDroidProviderFromBaseUrl('https://api.deepinfra.com/v1/openai')).toBe( + 'generic-chat-completion-api' + ); + }); + }); + + describe('inferDroidProviderFromModel', () => { + it('detects anthropic model naming', () => { + expect(inferDroidProviderFromModel('claude-sonnet-4-5-20250929')).toBe('anthropic'); + }); + + it('detects openai model naming', () => { + expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai'); + }); + }); + + describe('resolveDroidProvider', () => { + it('prefers explicit provider', () => { + expect( + resolveDroidProvider({ + provider: 'generic-chat-completion-api', + baseUrl: 'https://api.anthropic.com', + }) + ).toBe('generic-chat-completion-api'); + }); + + it('falls back to URL inference when provider hint is missing', () => { + expect(resolveDroidProvider({ baseUrl: 'https://api.openai.com/v1' })).toBe('openai'); + }); + + it('defaults to anthropic for legacy profiles without clear signal', () => { + expect(resolveDroidProvider({ baseUrl: 'http://127.0.0.1:8317' })).toBe('anthropic'); + expect(resolveDroidProvider({})).toBe('anthropic'); + }); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index 2a7e7924..a289c150 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -126,7 +126,8 @@ describe('DroidAdapter', () => { }); it('should build args with -m custom:ccs- prefix', () => { - const args = adapter.buildArgs('gemini', ['--verbose']); + const isolatedAdapter = new DroidAdapter(); + const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']); }); @@ -183,4 +184,27 @@ describe('DroidAdapter', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('buildArgs should use selector returned from Droid settings entry', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + + try { + const isolatedAdapter = new DroidAdapter(); + await isolatedAdapter.prepareCredentials({ + profile: 'gemini', + baseUrl: 'http://localhost:8317', + apiKey: 'dummy-key', + model: 'claude-sonnet-4-5-20250929', + }); + + const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); + expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']); + } finally { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); From f1567c0090c03d6b95d8cee94ce8dc29d57d3359 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 22:14:00 +0700 Subject: [PATCH 48/94] fix(droid): avoid interactive model arg prompt pollution - persist selector in ~/.factory/settings.json as settings.model - stop injecting '-m custom:...' for interactive droid launches - improve provider inference for localhost /v1 and qwen/deepseek/kimi - add regression tests for adapter, provider, and config manager --- README.md | 4 ++++ src/targets/droid-adapter.ts | 12 +++++++----- src/targets/droid-config-manager.ts | 9 ++++++--- src/targets/droid-provider.ts | 14 ++++++++++++++ tests/unit/targets/droid-adapter.test.ts | 4 ++-- .../unit/targets/droid-config-manager.test.ts | 5 +++++ tests/unit/targets/droid-provider.test.ts | 18 ++++++++++++++++++ tests/unit/targets/target-registry.test.ts | 12 +++++++++--- 8 files changed, 65 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5aff1eb9..0245c4a9 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,10 @@ For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hin `anthropic`, `openai`, or `generic-chat-completion-api`. If the hint is missing, CCS resolves provider from base URL/model at runtime. +CCS also persists Droid's active model selector in `~/.factory/settings.json` +(`model: custom:`). This avoids passing `-m` argv in interactive mode, +which Droid treats as queued prompt text. + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 10141469..2deeb1d7 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -2,7 +2,7 @@ * Droid Adapter * * TargetAdapter implementation for Factory Droid CLI. - * Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:`. + * Writes credentials + active model to ~/.factory/settings.json and spawns `droid`. */ import { spawn, ChildProcess } from 'child_process'; @@ -19,7 +19,6 @@ import { runCleanup } from '../errors'; export class DroidAdapter implements TargetAdapter { readonly type: TargetType = 'droid'; readonly displayName = 'Factory Droid'; - private readonly modelSelectorsByProfile = new Map(); private validateCredentials(creds: TargetCredentials): void { if (!creds.baseUrl?.trim()) { @@ -57,7 +56,9 @@ export class DroidAdapter implements TargetAdapter { apiKey: creds.apiKey, provider, }); - this.modelSelectorsByProfile.set(creds.profile, modelRef.selector); + if (!modelRef.selector) { + throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`); + } } buildArgs(profile: string, userArgs: string[]): string[] { @@ -66,8 +67,9 @@ export class DroidAdapter implements TargetAdapter { `Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed` ); } - const selector = this.modelSelectorsByProfile.get(profile) || `custom:ccs-${profile}`; - return ['-m', selector, ...userArgs]; + // Droid interactive mode treats unknown argv as queued prompt text. + // Model selection must be persisted in settings.json (`model`) instead of `-m`. + return [...userArgs]; } /** diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 1b48f1af..ac3c877a 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -52,6 +52,7 @@ export interface DroidManagedModelRef { } interface DroidSettings { + model?: string; customModels?: DroidCustomModelEntry[]; [key: string]: unknown; } @@ -346,19 +347,21 @@ export async function upsertCcsModel( settings.customModels.push(entry); } - writeDroidSettings(settings); - const index = settings.customModels.findIndex( (entry) => parseManagedProfile(entry.displayName) === profile ); const safeIndex = index >= 0 ? index : 0; const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex); + const selector = `custom:${selectorAlias}`; + // Droid interactive mode uses settings.model for default model selection. + settings.model = selector; + writeDroidSettings(settings); ref = { profile, displayName: entry.displayName, index: safeIndex, selectorAlias, - selector: `custom:${selectorAlias}`, + selector, }; } finally { if (release) await release(); diff --git a/src/targets/droid-provider.ts b/src/targets/droid-provider.ts index 3e6c4047..22a8d8c2 100644 --- a/src/targets/droid-provider.ts +++ b/src/targets/droid-provider.ts @@ -65,6 +65,8 @@ export function inferDroidProviderFromBaseUrl( const host = parsed.host.toLowerCase(); const pathname = parsed.pathname.toLowerCase(); + const isLocalHost = + host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]'); if ( host.includes('api.openai.com') || @@ -92,6 +94,11 @@ export function inferDroidProviderFromBaseUrl( return 'generic-chat-completion-api'; } + // Local OpenAI-compatible proxies are commonly exposed at /v1. + if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) { + return 'generic-chat-completion-api'; + } + return null; } @@ -116,6 +123,13 @@ export function inferDroidProviderFromModel( ) { return 'openai'; } + if ( + normalized.startsWith('qwen') || + normalized.startsWith('deepseek') || + normalized.startsWith('kimi') + ) { + return 'generic-chat-completion-api'; + } return null; } diff --git a/tests/unit/targets/droid-adapter.test.ts b/tests/unit/targets/droid-adapter.test.ts index 3c1501c6..5d82e7e8 100644 --- a/tests/unit/targets/droid-adapter.test.ts +++ b/tests/unit/targets/droid-adapter.test.ts @@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test'; import { DroidAdapter } from '../../../src/targets/droid-adapter'; describe('DroidAdapter.buildArgs', () => { - it('builds droid model args for valid profile names', () => { + it('passes user args without model injection for valid profile names', () => { const adapter = new DroidAdapter(); - expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']); + expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['--help']); }); it('rejects unsafe profile names', () => { diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 7b1f30b4..e96a76a5 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -45,6 +45,10 @@ describe('droid-config-manager', () => { expect(ref.selectorAlias).toBe('CCS-gemini-0'); expect(ref.selector).toBe('custom:CCS-gemini-0'); expect(ref.index).toBe(0); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.model).toBe('custom:CCS-gemini-0'); }); it('should create settings.json with customModels', async () => { @@ -63,6 +67,7 @@ describe('droid-config-manager', () => { expect(settings.customModels).toHaveLength(1); expect(settings.customModels[0].displayName).toBe('CCS gemini'); expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317'); + expect(settings.model).toBe('custom:CCS-gemini-0'); }); it('should update existing entry on second upsert', async () => { diff --git a/tests/unit/targets/droid-provider.test.ts b/tests/unit/targets/droid-provider.test.ts index 109764f0..2a9c5a3b 100644 --- a/tests/unit/targets/droid-provider.test.ts +++ b/tests/unit/targets/droid-provider.test.ts @@ -46,6 +46,18 @@ describe('droid-provider', () => { 'generic-chat-completion-api' ); }); + + it('detects localhost openai-compatible /v1 endpoints', () => { + expect(inferDroidProviderFromBaseUrl('http://127.0.0.1:1234/v1')).toBe( + 'generic-chat-completion-api' + ); + expect(inferDroidProviderFromBaseUrl('http://localhost:8317/v1/chat/completions')).toBe( + 'generic-chat-completion-api' + ); + expect(inferDroidProviderFromBaseUrl('http://[::1]:8317/v1')).toBe( + 'generic-chat-completion-api' + ); + }); }); describe('inferDroidProviderFromModel', () => { @@ -56,6 +68,12 @@ describe('droid-provider', () => { it('detects openai model naming', () => { expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai'); }); + + it('detects generic openai-compatible model families', () => { + expect(inferDroidProviderFromModel('qwen3-coder-plus')).toBe('generic-chat-completion-api'); + expect(inferDroidProviderFromModel('deepseek-v3.1')).toBe('generic-chat-completion-api'); + expect(inferDroidProviderFromModel('kimi-k2')).toBe('generic-chat-completion-api'); + }); }); describe('resolveDroidProvider', () => { diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index a289c150..c20b1bda 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -125,10 +125,16 @@ describe('DroidAdapter', () => { expect(adapter.supportsProfileType('copilot')).toBe(false); }); - it('should build args with -m custom:ccs- prefix', () => { + it('should keep interactive args clean (no model argv injection)', () => { const isolatedAdapter = new DroidAdapter(); const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); - expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']); + expect(args).toEqual(['--verbose']); + }); + + it('should not queue model selector as prompt when no user args', () => { + const isolatedAdapter = new DroidAdapter(); + const args = isolatedAdapter.buildArgs('codex', []); + expect(args).toEqual([]); }); it('should build minimal env (no ANTHROPIC_ vars)', () => { @@ -200,7 +206,7 @@ describe('DroidAdapter', () => { }); const args = isolatedAdapter.buildArgs('gemini', ['--verbose']); - expect(args).toEqual(['-m', 'custom:CCS-gemini-0', '--verbose']); + expect(args).toEqual(['--verbose']); } finally { if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME; From bb9240e19591f20db9d6c3c9a65be56a25784660 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 15:20:26 +0000 Subject: [PATCH 49/94] chore(release): 7.50.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd760d42..a612368e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.1", + "version": "7.50.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From bc079bc88656435da1d5b4b2329f89c90c910d1b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 22:59:15 +0700 Subject: [PATCH 50/94] feat(dashboard): add dedicated Factory Droid diagnostics page - add /droid route under a new Compatible CLIs sidebar section - expose /api/droid/diagnostics and /api/droid/settings/raw for install and BYOK visibility - include read-only settings.json viewer plus model/provider breakdown - add unit coverage for droid dashboard service parsing and path logic --- README.md | 3 + src/web-server/routes/droid-routes.ts | 34 ++ src/web-server/routes/index.ts | 4 + .../services/compatible-cli-types.ts | 70 ++++ .../services/droid-dashboard-service.ts | 298 ++++++++++++++ .../droid-dashboard-service.test.ts | 114 ++++++ ui/src/App.tsx | 9 + ui/src/components/layout/app-sidebar.tsx | 5 + ui/src/hooks/use-droid.ts | 119 ++++++ ui/src/pages/droid.tsx | 380 ++++++++++++++++++ ui/src/pages/index.tsx | 2 + 11 files changed, 1038 insertions(+) create mode 100644 src/web-server/routes/droid-routes.ts create mode 100644 src/web-server/services/compatible-cli-types.ts create mode 100644 src/web-server/services/droid-dashboard-service.ts create mode 100644 tests/unit/web-server/droid-dashboard-service.test.ts create mode 100644 ui/src/hooks/use-droid.ts create mode 100644 ui/src/pages/droid.tsx diff --git a/README.md b/README.md index 0245c4a9..cca63796 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -166,6 +167,8 @@ CCS also persists Droid's active model selector in `~/.factory/settings.json` (`model: custom:`). This avoids passing `-m` argv in interactive mode, which Droid treats as queued prompt text. +Dashboard parity: `ccs config` -> `Factory Droid` + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts new file mode 100644 index 00000000..0bbb19e5 --- /dev/null +++ b/src/web-server/routes/droid-routes.ts @@ -0,0 +1,34 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + getDroidDashboardDiagnostics, + getDroidRawSettings, +} from '../services/droid-dashboard-service'; + +const router = Router(); + +/** + * GET /api/droid/diagnostics + * Dashboard-ready Droid installation + BYOK configuration diagnostics. + */ +router.get('/diagnostics', (_req: Request, res: Response): void => { + try { + res.json(getDroidDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/droid/settings/raw + * Raw ~/.factory/settings.json payload for read-only viewer. + */ +router.get('/settings/raw', (_req: Request, res: Response): void => { + try { + res.json(getDroidRawSettings()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index f9517635..09097156 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; +import droidRoutes from './droid-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +// ==================== Droid ==================== +apiRoutes.use('/droid', droidRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts new file mode 100644 index 00000000..6493f783 --- /dev/null +++ b/src/web-server/services/compatible-cli-types.ts @@ -0,0 +1,70 @@ +export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: DroidBinarySource; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface DroidByokDiagnostics { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + globalConfig: DroidConfigFileDiagnostics; + }; + byok: DroidByokDiagnostics; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettingsResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts new file mode 100644 index 00000000..050bffd9 --- /dev/null +++ b/src/web-server/services/droid-dashboard-service.ts @@ -0,0 +1,298 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { detectDroidCli } from '../../targets/droid-detector'; +import type { + DroidByokDiagnostics, + DroidConfigFileDiagnostics, + DroidCustomModelDiagnostics, + DroidDashboardDiagnostics, + DroidRawSettingsResponse, +} from './compatible-cli-types'; + +interface DroidConfigPaths { + settingsPath: string; + settingsDisplayPath: string; + globalConfigPath: string; + globalConfigDisplayPath: string; +} + +interface JsonFileProbe { + diagnostics: DroidConfigFileDiagnostics; + json: Record | null; + rawText: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function parseHost(value: string): string | null { + try { + return new URL(value).host || null; + } catch { + return null; + } +} + +export function maskApiKeyPreview(value: string): string { + if (!value) return ''; + const suffix = value.slice(-4); + return `***${suffix}`; +} + +function isCcsManagedDisplayName(displayName: string): boolean { + return displayName.startsWith('CCS ') || displayName.startsWith('ccs-'); +} + +export function resolveDroidConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): DroidConfigPaths { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const byokBase = env.CCS_HOME || homeDir; + const settingsPath = path.join(byokBase, '.factory', 'settings.json'); + + const globalConfigRoot = + platform === 'win32' + ? env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') + : env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); + const globalConfigPath = path.join(globalConfigRoot, 'factory', 'config.json'); + + return { + settingsPath, + settingsDisplayPath: '~/.factory/settings.json', + globalConfigPath, + globalConfigDisplayPath: + platform === 'win32' ? '%APPDATA%/factory/config.json' : '~/.config/factory/config.json', + }; +} + +function getBinaryVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim() + .split('\n')[0] + .trim(); + } catch { + return null; + } +} + +function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { + if (!fs.existsSync(filePath)) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const stat = fs.lstatSync(filePath); + const diagnostics: DroidConfigFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = fs.readFileSync(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { + const rows: DroidCustomModelDiagnostics[] = []; + const providerBreakdown: Record = {}; + let invalidModelEntryCount = 0; + + const source = Array.isArray(customModelsValue) + ? customModelsValue + : isObject(customModelsValue) + ? Object.values(customModelsValue) + : []; + + for (const item of source) { + if (!isObject(item)) { + invalidModelEntryCount += 1; + continue; + } + + const displayName = asString(item.displayName); + const model = asString(item.model); + const baseUrl = asString(item.baseUrl); + const providerRaw = asString(item.provider); + const apiKey = asString(item.apiKey); + + if (!displayName || !model || !baseUrl || !providerRaw) { + invalidModelEntryCount += 1; + continue; + } + + const provider = providerRaw.toLowerCase(); + providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1; + + rows.push({ + displayName, + model, + provider, + baseUrl, + host: parseHost(baseUrl), + maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + isCcsManaged: isCcsManagedDisplayName(displayName), + apiKeyState: apiKey ? 'set' : 'missing', + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, + }); + } + + const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length; + + return { + activeModelSelector: null, + customModelCount: rows.length, + ccsManagedCount, + userManagedCount: rows.length - ccsManagedCount, + invalidModelEntryCount, + providerBreakdown, + customModels: rows, + }; +} + +export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { + const paths = resolveDroidConfigPaths(); + const binaryPath = detectDroidCli(); + + const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; + + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + const globalConfigProbe = readJsonFileProbe( + paths.globalConfigPath, + 'Global config', + paths.globalConfigDisplayPath + ); + + const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); + byok.activeModelSelector = asString(settingsProbe.json?.model); + + const warnings: string[] = []; + if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.'); + if (settingsProbe.diagnostics.parseError) { + warnings.push('~/.factory/settings.json contains invalid JSON.'); + } + if (byok.invalidModelEntryCount > 0) { + warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); + } + if (globalConfigProbe.diagnostics.parseError) { + warnings.push('Global Droid config JSON is invalid.'); + } + + return { + binary: { + installed: !!binaryPath, + path: binaryPath, + installDir: binaryPath ? path.dirname(binaryPath) : null, + source, + version: binaryPath ? getBinaryVersion(binaryPath) : null, + overridePath: process.env.CCS_DROID_PATH || null, + }, + files: { + settings: settingsProbe.diagnostics, + globalConfig: globalConfigProbe.diagnostics, + }, + byok, + warnings, + docsReference: { + providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'], + settingsHierarchy: [ + 'project-level config', + 'user-level config', + 'home-level config', + 'CLI flags and env vars', + ], + notes: [ + 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Interactive model selection uses settings.model (custom:)', + 'droid exec supports --model for one-off execution mode', + ], + }, + }; +} + +export function getDroidRawSettings(): DroidRawSettingsResponse { + const paths = resolveDroidConfigPaths(); + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + + return { + path: paths.settingsDisplayPath, + resolvedPath: paths.settingsPath, + exists: settingsProbe.diagnostics.exists, + mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: settingsProbe.rawText, + settings: settingsProbe.json, + parseError: settingsProbe.diagnostics.parseError, + }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts new file mode 100644 index 00000000..23249fe3 --- /dev/null +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + getDroidRawSettings, + maskApiKeyPreview, + resolveDroidConfigPaths, + summarizeDroidCustomModels, +} from '../../../src/web-server/services/droid-dashboard-service'; + +const testRoot = path.join(os.tmpdir(), `ccs-droid-dashboard-test-${Date.now()}`); + +beforeEach(() => { + fs.mkdirSync(testRoot, { recursive: true }); + process.env.CCS_HOME = testRoot; +}); + +afterEach(() => { + delete process.env.CCS_HOME; + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +}); + +describe('droid-dashboard-service', () => { + it('resolves droid config paths on unix-like platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'darwin', + env: { + CCS_HOME: '/tmp/ccs-home', + XDG_CONFIG_HOME: '/tmp/xdg', + } as NodeJS.ProcessEnv, + homeDir: '/Users/tester', + }); + + expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); + expect(resolved.globalConfigPath).toBe('/tmp/xdg/factory/config.json'); + expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); + expect(resolved.globalConfigDisplayPath).toBe('~/.config/factory/config.json'); + }); + + it('resolves droid config paths on windows platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'win32', + env: { + APPDATA: 'C:/Users/test/AppData/Roaming', + } as NodeJS.ProcessEnv, + homeDir: 'C:/Users/test', + }); + + expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); + expect(resolved.globalConfigPath).toBe( + path.join('C:/Users/test/AppData/Roaming', 'factory', 'config.json') + ); + expect(resolved.globalConfigDisplayPath).toBe('%APPDATA%/factory/config.json'); + }); + + it('masks api key preview with only suffix', () => { + expect(maskApiKeyPreview('sk-abcdefghijklmnop')).toBe('***mnop'); + }); + + it('summarizes custom model entries with provider breakdown and ownership', () => { + const summary = summarizeDroidCustomModels([ + { + displayName: 'CCS codex', + model: 'gpt-5-codex', + baseUrl: 'http://127.0.0.1:8317/v1', + apiKey: 'secret-token-1234', + provider: 'openai', + }, + { + displayName: 'Factory team profile', + model: 'claude-sonnet-4-5', + baseUrl: 'https://api.anthropic.com', + apiKey: 'another-token-9999', + provider: 'anthropic', + }, + { + displayName: 'bad entry', + }, + ]); + + expect(summary.customModelCount).toBe(2); + expect(summary.ccsManagedCount).toBe(1); + expect(summary.userManagedCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(1); + expect(summary.providerBreakdown.openai).toBe(1); + expect(summary.providerBreakdown.anthropic).toBe(1); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + + it('returns raw settings payload for missing settings file', () => { + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(false); + expect(raw.path).toBe('~/.factory/settings.json'); + expect(raw.rawText).toBe('{}'); + expect(raw.settings).toBeNull(); + }); + + it('returns parseError when settings.json is invalid JSON', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); + + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(true); + expect(raw.parseError).toBeString(); + expect(raw.settings).toBeNull(); + expect(raw.rawText).toContain('invalid-json'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a823ae4..62ecb304 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ const CliproxyControlPanelPage = lazy(() => ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); +const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -117,6 +118,14 @@ export default function App() { } /> + }> + + + } + /> ; + customModels: DroidCustomModelDiagnostics[]; + }; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettings { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} + +async function fetchDroidDiagnostics(): Promise { + const res = await fetch(withApiBase('/droid/diagnostics')); + if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); + return res.json(); +} + +async function fetchDroidRawSettings(): Promise { + const res = await fetch(withApiBase('/droid/settings/raw')); + if (!res.ok) throw new Error('Failed to fetch Droid raw settings'); + return res.json(); +} + +export function useDroid() { + const diagnosticsQuery = useQuery({ + queryKey: ['droid-diagnostics'], + queryFn: fetchDroidDiagnostics, + refetchInterval: 10000, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['droid-raw-settings'], + queryFn: fetchDroidRawSettings, + }); + + return useMemo( + () => ({ + diagnostics: diagnosticsQuery.data, + diagnosticsLoading: diagnosticsQuery.isLoading, + diagnosticsError: diagnosticsQuery.error, + refetchDiagnostics: diagnosticsQuery.refetch, + + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + rawSettingsError: rawSettingsQuery.error, + refetchRawSettings: rawSettingsQuery.refetch, + }), + [ + diagnosticsQuery.data, + diagnosticsQuery.isLoading, + diagnosticsQuery.error, + diagnosticsQuery.refetch, + rawSettingsQuery.data, + rawSettingsQuery.isLoading, + rawSettingsQuery.error, + rawSettingsQuery.refetch, + ] + ); +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx new file mode 100644 index 00000000..5792ef09 --- /dev/null +++ b/ui/src/pages/droid.tsx @@ -0,0 +1,380 @@ +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + AlertTriangle, + CheckCircle2, + Copy, + FileCode2, + Folder, + GripVertical, + Loader2, + RefreshCw, + Server, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { useDroid } from '@/hooks/use-droid'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function DroidPage() { + const { + diagnostics, + diagnosticsLoading, + diagnosticsError, + refetchDiagnostics, + rawSettings, + rawSettingsLoading, + refetchRawSettings, + } = useDroid(); + + const [copied, setCopied] = useState(false); + + const copyRawSettings = async () => { + if (!rawSettings?.rawText) return; + await navigator.clipboard.writeText(rawSettings.rawText); + setCopied(true); + toast.success('Droid settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + const refreshAll = async () => { + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + }; + + const customModels = diagnostics?.byok.customModels ?? []; + const providerRows = useMemo( + () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), + [diagnostics?.byok.providerBreakdown] + ); + + const renderOverview = () => { + if (diagnosticsLoading) { + return ( +
+ + Loading Droid diagnostics... +
+ ); + } + + if (diagnosticsError || !diagnostics) { + return ( +
+ Failed to load Droid diagnostics. +
+ ); + } + + return ( + +
+ + + + + Runtime & Installation + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + +
+ + + + + +
+
+ + + + + + Config Files + + + + {[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => ( +
+
+ {file.label} + {file.exists ? ( + + ) : ( + + )} +
+ + + + + {file.parseError && ( +

Parse warning: {file.parseError}

+ )} + {file.readError && ( +

Read warning: {file.readError}

+ )} +
+ ))} +
+
+ + + + + + BYOK Summary + + + + + + + + + +
+

Providers

+
+ {providerRows.length === 0 && ( + + none + + )} + {providerRows.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+
+
+ + + + + + Docs-Aligned Notes + + + + {diagnostics.docsReference.notes.map((note) => ( +

+ - {note} +

+ ))} + +

+ Provider values: {diagnostics.docsReference.providerValues.join(', ')} +

+

+ Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')} +

+
+
+ + + + Custom Models + + +
+
+ Name / Model + Provider + Base URL +
+ +
+ {customModels.length === 0 && ( +
+ No custom models +
+ )} + {customModels.map((model) => ( +
+
+

{model.displayName}

+

{model.model}

+
+
+

{model.provider}

+

{model.apiKeyPreview || 'no-key'}

+
+
+

+ {model.host || model.baseUrl} +

+

+ {model.baseUrl} +

+
+
+ ))} +
+
+
+
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ ); + }; + + return ( +
+ + +
{renderOverview()}
+
+ + + + +
+
+
+

+ + Droid BYOK Settings +

+

+ {rawSettings?.path || '~/.factory/settings.json'} +

+
+
+ + +
+
+ +
+ {rawSettingsLoading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {rawSettings?.parseError && ( +
+ Parse warning: {rawSettings.parseError} +
+ )} +
+
+ {}} + language="json" + readonly + minHeight="100%" + /> +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index dda9ae12..b4ef665f 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; export { UpdatesPage } from './updates'; + +export { DroidPage } from './droid'; From c6d2e71ec2f4e2587b13e1bb7d01d53499cd0cdf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:26:23 +0700 Subject: [PATCH 51/94] fix(dashboard): align droid config diagnostics with factory paths - replace ~/.config/factory/config.json diagnostics with legacy ~/.factory/config.json - keep ~/.factory/settings.json as the primary BYOK source in UI + API payload - update droid dashboard tests and frontend typings for legacyConfig --- .../services/compatible-cli-types.ts | 2 +- .../services/droid-dashboard-service.ts | 32 ++++++++----------- .../droid-dashboard-service.test.ts | 15 +++------ ui/src/hooks/use-droid.ts | 2 +- ui/src/pages/droid.tsx | 2 +- 5 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index 6493f783..bb9898e2 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -48,7 +48,7 @@ export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { settings: DroidConfigFileDiagnostics; - globalConfig: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; }; byok: DroidByokDiagnostics; warnings: string[]; diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 050bffd9..d342c875 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -14,8 +14,8 @@ import type { interface DroidConfigPaths { settingsPath: string; settingsDisplayPath: string; - globalConfigPath: string; - globalConfigDisplayPath: string; + legacyConfigPath: string; + legacyConfigDisplayPath: string; } interface JsonFileProbe { @@ -57,25 +57,18 @@ export function resolveDroidConfigPaths( homeDir?: string; } = {} ): DroidConfigPaths { - const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const homeDir = options.homeDir ?? os.homedir(); const byokBase = env.CCS_HOME || homeDir; const settingsPath = path.join(byokBase, '.factory', 'settings.json'); - - const globalConfigRoot = - platform === 'win32' - ? env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') - : env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); - const globalConfigPath = path.join(globalConfigRoot, 'factory', 'config.json'); + const legacyConfigPath = path.join(byokBase, '.factory', 'config.json'); return { settingsPath, settingsDisplayPath: '~/.factory/settings.json', - globalConfigPath, - globalConfigDisplayPath: - platform === 'win32' ? '%APPDATA%/factory/config.json' : '~/.config/factory/config.json', + legacyConfigPath, + legacyConfigDisplayPath: '~/.factory/config.json', }; } @@ -225,10 +218,10 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { 'BYOK settings', paths.settingsDisplayPath ); - const globalConfigProbe = readJsonFileProbe( - paths.globalConfigPath, - 'Global config', - paths.globalConfigDisplayPath + const legacyConfigProbe = readJsonFileProbe( + paths.legacyConfigPath, + 'Legacy config', + paths.legacyConfigDisplayPath ); const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); @@ -242,8 +235,8 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { if (byok.invalidModelEntryCount > 0) { warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); } - if (globalConfigProbe.diagnostics.parseError) { - warnings.push('Global Droid config JSON is invalid.'); + if (legacyConfigProbe.diagnostics.parseError) { + warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.'); } return { @@ -257,7 +250,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { }, files: { settings: settingsProbe.diagnostics, - globalConfig: globalConfigProbe.diagnostics, + legacyConfig: legacyConfigProbe.diagnostics, }, byok, warnings, @@ -271,6 +264,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { ], notes: [ 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Factory docs mention legacy support for ~/.factory/config.json', 'Interactive model selection uses settings.model (custom:)', 'droid exec supports --model for one-off execution mode', ], diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index 23249fe3..f1f388a7 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -29,31 +29,26 @@ describe('droid-dashboard-service', () => { platform: 'darwin', env: { CCS_HOME: '/tmp/ccs-home', - XDG_CONFIG_HOME: '/tmp/xdg', } as NodeJS.ProcessEnv, homeDir: '/Users/tester', }); expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); - expect(resolved.globalConfigPath).toBe('/tmp/xdg/factory/config.json'); + expect(resolved.legacyConfigPath).toBe('/tmp/ccs-home/.factory/config.json'); expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); - expect(resolved.globalConfigDisplayPath).toBe('~/.config/factory/config.json'); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); }); it('resolves droid config paths on windows platforms', () => { const resolved = resolveDroidConfigPaths({ platform: 'win32', - env: { - APPDATA: 'C:/Users/test/AppData/Roaming', - } as NodeJS.ProcessEnv, + env: {} as NodeJS.ProcessEnv, homeDir: 'C:/Users/test', }); expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); - expect(resolved.globalConfigPath).toBe( - path.join('C:/Users/test/AppData/Roaming', 'factory', 'config.json') - ); - expect(resolved.globalConfigDisplayPath).toBe('%APPDATA%/factory/config.json'); + expect(resolved.legacyConfigPath).toBe(path.join('C:/Users/test', '.factory', 'config.json')); + expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json'); }); it('masks api key preview with only suffix', () => { diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index ec939b50..2a647f6c 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -40,7 +40,7 @@ export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { settings: DroidConfigFileDiagnostics; - globalConfig: DroidConfigFileDiagnostics; + legacyConfig: DroidConfigFileDiagnostics; }; byok: { activeModelSelector: string | null; diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 5792ef09..3f111297 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -147,7 +147,7 @@ export function DroidPage() { - {[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => ( + {[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => (
{file.label} From e9eab712b3f60d1dcf45746df69dabac29beb299 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:39:39 +0700 Subject: [PATCH 52/94] refactor(dashboard): reuse compatible CLI settings editor stack - add shared backend JSON file probe/write utilities for compatible CLI settings - add shared frontend raw JSON editor panel and wire Droid page to it - support PUT save flow with validation and mtime conflict handling --- src/web-server/routes/droid-routes.ts | 39 +++- .../compatible-cli-json-file-service.ts | 212 ++++++++++++++++++ .../services/droid-dashboard-service.ts | 107 +++------ .../droid-dashboard-service.test.ts | 39 ++++ .../raw-json-settings-editor-panel.tsx | 103 +++++++++ ui/src/hooks/use-droid.ts | 48 +++- ui/src/pages/droid.tsx | 142 ++++++------ 7 files changed, 542 insertions(+), 148 deletions(-) create mode 100644 src/web-server/services/compatible-cli-json-file-service.ts create mode 100644 ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts index 0bbb19e5..8cc81950 100644 --- a/src/web-server/routes/droid-routes.ts +++ b/src/web-server/routes/droid-routes.ts @@ -1,8 +1,11 @@ import type { Request, Response } from 'express'; import { Router } from 'express'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidDashboardDiagnostics, getDroidRawSettings, + saveDroidRawSettings, } from '../services/droid-dashboard-service'; const router = Router(); @@ -21,7 +24,7 @@ router.get('/diagnostics', (_req: Request, res: Response): void => { /** * GET /api/droid/settings/raw - * Raw ~/.factory/settings.json payload for read-only viewer. + * Raw ~/.factory/settings.json payload for editor. */ router.get('/settings/raw', (_req: Request, res: Response): void => { try { @@ -31,4 +34,38 @@ router.get('/settings/raw', (_req: Request, res: Response): void => { } }); +/** + * PUT /api/droid/settings/raw + * Save raw ~/.factory/settings.json payload from dashboard editor. + */ +router.put('/settings/raw', (req: Request, res: Response): void => { + try { + const { rawText, expectedMtime } = req.body ?? {}; + + if (typeof rawText !== 'string') { + res.status(400).json({ error: 'rawText must be a string.' }); + return; + } + if ( + expectedMtime !== undefined && + (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime)) + ) { + res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' }); + return; + } + + res.json(saveDroidRawSettings({ rawText, expectedMtime })); + } catch (error) { + if (error instanceof DroidRawSettingsValidationError) { + res.status(400).json({ error: error.message }); + return; + } + if (error instanceof DroidRawSettingsConflictError) { + res.status(409).json({ error: error.message, mtime: error.mtime }); + return; + } + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts new file mode 100644 index 00000000..fe425cf8 --- /dev/null +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface JsonFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface JsonFileProbe { + diagnostics: JsonFileDiagnostics; + json: Record | null; + rawText: string; +} + +interface WriteJsonObjectFileInput { + filePath: string; + rawText: string; + expectedMtime?: number; + fileLabel?: string; + dirMode?: number; + fileMode?: number; +} + +interface WriteJsonObjectFileResult { + mtime: number; +} + +export class JsonFileValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonFileValidationError'; + } +} + +export class JsonFileConflictError extends Error { + readonly code = 'CONFLICT'; + readonly mtime: number; + + constructor(message: string, mtime: number) { + super(message); + this.name = 'JsonFileConflictError'; + this.mtime = mtime; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function probeJsonObjectFile( + filePath: string, + label: string, + displayPath: string +): JsonFileProbe { + if (!fs.existsSync(filePath)) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const stat = fs.lstatSync(filePath); + const diagnostics: JsonFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = fs.readFileSync(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function parseJsonObjectText( + rawText: string, + fieldName = 'rawText' +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch (error) { + throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`); + } + + if (!isObject(parsed)) { + throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`); + } + + return parsed; +} + +export function writeJsonObjectFileAtomic( + input: WriteJsonObjectFileInput +): WriteJsonObjectFileResult { + const fileLabel = input.fileLabel || path.basename(input.filePath); + const parsed = parseJsonObjectText(input.rawText, fileLabel); + const targetPath = input.filePath; + const targetDir = path.dirname(targetPath); + const tempPath = targetPath + '.tmp'; + const dirMode = input.dirMode ?? 0o700; + const fileMode = input.fileMode ?? 0o600; + + fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); + + if (fs.existsSync(targetPath)) { + const stat = fs.lstatSync(targetPath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); + } + if (!stat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`); + } + + if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) { + throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs); + } + if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) { + throw new JsonFileConflictError('File modified externally.', stat.mtimeMs); + } + } + + let wroteTemp = false; + try { + if (fs.existsSync(tempPath)) { + const tempStat = fs.lstatSync(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + } + + fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + wroteTemp = true; + + const tempStat = fs.lstatSync(tempPath); + if (tempStat.isSymbolicLink()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); + } + if (!tempStat.isFile()) { + throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); + } + + fs.renameSync(tempPath, targetPath); + wroteTemp = false; + + try { + fs.chmodSync(targetPath, fileMode); + } catch { + // Best-effort permission hardening. + } + + const stat = fs.statSync(targetPath); + return { mtime: stat.mtimeMs }; + } finally { + if (wroteTemp && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index d342c875..8a0e24a1 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -1,15 +1,19 @@ -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { execFileSync } from 'child_process'; import { detectDroidCli } from '../../targets/droid-detector'; import type { DroidByokDiagnostics, - DroidConfigFileDiagnostics, DroidCustomModelDiagnostics, DroidDashboardDiagnostics, DroidRawSettingsResponse, } from './compatible-cli-types'; +import { + JsonFileConflictError, + JsonFileValidationError, + probeJsonObjectFile, + writeJsonObjectFileAtomic, +} from './compatible-cli-json-file-service'; interface DroidConfigPaths { settingsPath: string; @@ -18,12 +22,21 @@ interface DroidConfigPaths { legacyConfigDisplayPath: string; } -interface JsonFileProbe { - diagnostics: DroidConfigFileDiagnostics; - json: Record | null; +interface SaveDroidRawSettingsInput { rawText: string; + expectedMtime?: number; } +interface SaveDroidRawSettingsResult { + success: true; + mtime: number; +} + +export { + JsonFileConflictError as DroidRawSettingsConflictError, + JsonFileValidationError as DroidRawSettingsValidationError, +}; + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -87,69 +100,6 @@ function getBinaryVersion(binaryPath: string): string | null { } } -function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { - if (!fs.existsSync(filePath)) { - return { - diagnostics: { - label, - path: displayPath, - resolvedPath: filePath, - exists: false, - isSymlink: false, - isRegularFile: false, - sizeBytes: null, - mtimeMs: null, - parseError: null, - readError: null, - }, - json: null, - rawText: '{}', - }; - } - - const stat = fs.lstatSync(filePath); - const diagnostics: DroidConfigFileDiagnostics = { - label, - path: displayPath, - resolvedPath: filePath, - exists: true, - isSymlink: stat.isSymbolicLink(), - isRegularFile: stat.isFile(), - sizeBytes: stat.size, - mtimeMs: stat.mtimeMs, - parseError: null, - readError: null, - }; - - if (diagnostics.isSymlink) { - diagnostics.readError = 'Refusing symlink file for safety.'; - return { diagnostics, json: null, rawText: '{}' }; - } - - if (!diagnostics.isRegularFile) { - diagnostics.readError = 'Target is not a regular file.'; - return { diagnostics, json: null, rawText: '{}' }; - } - - try { - const rawText = fs.readFileSync(filePath, 'utf8'); - try { - const parsed = JSON.parse(rawText); - if (!isObject(parsed)) { - diagnostics.parseError = 'JSON root must be an object.'; - return { diagnostics, json: null, rawText }; - } - return { diagnostics, json: parsed, rawText }; - } catch (error) { - diagnostics.parseError = (error as Error).message; - return { diagnostics, json: null, rawText }; - } - } catch (error) { - diagnostics.readError = (error as Error).message; - return { diagnostics, json: null, rawText: '{}' }; - } -} - export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { const rows: DroidCustomModelDiagnostics[] = []; const providerBreakdown: Record = {}; @@ -213,12 +163,12 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath ); - const legacyConfigProbe = readJsonFileProbe( + const legacyConfigProbe = probeJsonObjectFile( paths.legacyConfigPath, 'Legacy config', paths.legacyConfigDisplayPath @@ -274,7 +224,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { export function getDroidRawSettings(): DroidRawSettingsResponse { const paths = resolveDroidConfigPaths(); - const settingsProbe = readJsonFileProbe( + const settingsProbe = probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath @@ -290,3 +240,18 @@ export function getDroidRawSettings(): DroidRawSettingsResponse { parseError: settingsProbe.diagnostics.parseError, }; } + +export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { + const paths = resolveDroidConfigPaths(); + if (typeof input.rawText !== 'string') { + throw new JsonFileValidationError('rawText must be a string.'); + } + + const saved = writeJsonObjectFileAtomic({ + filePath: paths.settingsPath, + rawText: input.rawText, + expectedMtime: input.expectedMtime, + fileLabel: 'settings.json', + }); + return { success: true, mtime: saved.mtime }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index f1f388a7..fcf2542a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -3,9 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + DroidRawSettingsConflictError, + DroidRawSettingsValidationError, getDroidRawSettings, maskApiKeyPreview, resolveDroidConfigPaths, + saveDroidRawSettings, summarizeDroidCustomModels, } from '../../../src/web-server/services/droid-dashboard-service'; @@ -106,4 +109,40 @@ describe('droid-dashboard-service', () => { expect(raw.settings).toBeNull(); expect(raw.rawText).toContain('invalid-json'); }); + + it('saves valid raw settings content', () => { + const result = saveDroidRawSettings({ + rawText: JSON.stringify({ + model: 'custom:test-model', + customModels: [], + }), + }); + + const settingsPath = path.join(testRoot, '.factory', 'settings.json'); + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + expect(result.success).toBe(true); + expect(result.mtime).toBeGreaterThan(0); + expect(written.model).toBe('custom:test-model'); + }); + + it('rejects invalid JSON while saving raw settings', () => { + expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( + DroidRawSettingsValidationError + ); + }); + + it('rejects stale writes with conflict error', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); + + expect(() => + saveDroidRawSettings({ + rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), + expectedMtime: 1, + }) + ).toThrow(DroidRawSettingsConflictError); + }); }); diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx new file mode 100644 index 00000000..3b0cf8bf --- /dev/null +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +interface RawJsonSettingsEditorPanelProps { + title: string; + pathLabel: string; + loading: boolean; + parseWarning: string | null | undefined; + value: string; + dirty: boolean; + saving: boolean; + saveDisabled: boolean; + onChange: (nextValue: string) => void; + onSave: () => Promise | void; + onRefresh: () => Promise | void; +} + +export function RawJsonSettingsEditorPanel({ + title, + pathLabel, + loading, + parseWarning, + value, + dirty, + saving, + saveDisabled, + onChange, + onSave, + onRefresh, +}: RawJsonSettingsEditorPanelProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!value) return; + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success('Settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+
+

+ + {title} + {dirty && ( + + Unsaved + + )} +

+

{pathLabel}

+
+
+ + + +
+
+ +
+ {loading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {parseWarning && ( +
+ Parse warning: {parseWarning} +
+ )} +
+
+ +
+
+
+ )} +
+
+ ); +} diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index 2a647f6c..a4b2c062 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { withApiBase } from '@/lib/api-client'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiConflictError, withApiBase } from '@/lib/api-client'; export interface DroidBinaryDiagnostics { installed: boolean; @@ -69,6 +69,16 @@ export interface DroidRawSettings { parseError: string | null; } +interface SaveDroidRawSettingsInput { + rawText: string; + expectedMtime?: number; +} + +interface SaveDroidRawSettingsResponse { + success: true; + mtime: number; +} + async function fetchDroidDiagnostics(): Promise { const res = await fetch(withApiBase('/droid/diagnostics')); if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); @@ -81,7 +91,26 @@ async function fetchDroidRawSettings(): Promise { return res.json(); } +async function saveDroidRawSettings( + data: SaveDroidRawSettingsInput +): Promise { + const res = await fetch(withApiBase('/droid/settings/raw'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new ApiConflictError('Droid raw settings changed externally'); + + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || 'Failed to save Droid raw settings'); + } + return res.json(); +} + export function useDroid() { + const queryClient = useQueryClient(); + const diagnosticsQuery = useQuery({ queryKey: ['droid-diagnostics'], queryFn: fetchDroidDiagnostics, @@ -93,6 +122,14 @@ export function useDroid() { queryFn: fetchDroidRawSettings, }); + const saveRawSettingsMutation = useMutation({ + mutationFn: saveDroidRawSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] }); + queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] }); + }, + }); + return useMemo( () => ({ diagnostics: diagnosticsQuery.data, @@ -104,6 +141,10 @@ export function useDroid() { rawSettingsLoading: rawSettingsQuery.isLoading, rawSettingsError: rawSettingsQuery.error, refetchRawSettings: rawSettingsQuery.refetch, + + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, }), [ diagnosticsQuery.data, @@ -114,6 +155,9 @@ export function useDroid() { rawSettingsQuery.isLoading, rawSettingsQuery.error, rawSettingsQuery.refetch, + saveRawSettingsMutation.mutate, + saveRawSettingsMutation.mutateAsync, + saveRawSettingsMutation.isPending, ] ); } diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 3f111297..d53256cb 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -4,24 +4,21 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { AlertTriangle, CheckCircle2, - Copy, - FileCode2, Folder, GripVertical, Loader2, - RefreshCw, Server, ShieldCheck, TerminalSquare, XCircle, } from 'lucide-react'; import { useDroid } from '@/hooks/use-droid'; -import { Button } from '@/components/ui/button'; +import { isApiConflictError } from '@/lib/api-client'; +import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { CodeEditor } from '@/components/shared/code-editor'; import { cn } from '@/lib/utils'; function formatTimestamp(value: number | null | undefined): string { @@ -36,6 +33,18 @@ function formatBytes(value: number | null | undefined): string { return `${(value / (1024 * 1024)).toFixed(2)} MB`; } +function validateJsonObject(text: string): { valid: true } | { valid: false; error: string } { + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { valid: false, error: 'JSON root must be an object.' }; + } + return { valid: true }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} + function DetailRow({ label, value, @@ -62,27 +71,48 @@ export function DroidPage() { rawSettings, rawSettingsLoading, refetchRawSettings, + saveRawSettingsAsync, + isSavingRawSettings, } = useDroid(); - const [copied, setCopied] = useState(false); - - const copyRawSettings = async () => { - if (!rawSettings?.rawText) return; - await navigator.clipboard.writeText(rawSettings.rawText); - setCopied(true); - toast.success('Droid settings copied to clipboard'); - window.setTimeout(() => setCopied(false), 1500); - }; + const [rawDraftText, setRawDraftText] = useState(null); + const rawBaseText = rawSettings?.rawText ?? '{}'; + const rawEditorText = rawDraftText ?? rawBaseText; + const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; const refreshAll = async () => { await Promise.all([refetchDiagnostics(), refetchRawSettings()]); }; + const handleSaveRawSettings = async () => { + if (!rawEditorValidation.valid) { + toast.error(`Invalid JSON: ${rawEditorValidation.error}`); + return; + } + + try { + await saveRawSettingsAsync({ + rawText: rawEditorText, + expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, + }); + setRawDraftText(null); + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + toast.success('Droid settings saved'); + } catch (error) { + if (isApiConflictError(error)) { + toast.error('Droid settings changed externally. Refresh and retry.'); + } else { + toast.error((error as Error).message || 'Failed to save Droid settings'); + } + } + }; + const customModels = diagnostics?.byok.customModels ?? []; const providerRows = useMemo( () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), [diagnostics?.byok.providerBreakdown] ); + const rawEditorValidation = validateJsonObject(rawEditorText); const renderOverview = () => { if (diagnosticsLoading) { @@ -313,66 +343,30 @@ export function DroidPage() { -
-
-
-

- - Droid BYOK Settings -

-

- {rawSettings?.path || '~/.factory/settings.json'} -

-
-
- - -
-
- -
- {rawSettingsLoading ? ( -
- - Loading settings.json... -
- ) : ( -
- {rawSettings?.parseError && ( -
- Parse warning: {rawSettings.parseError} -
- )} -
-
- {}} - language="json" - readonly - minHeight="100%" - /> -
-
-
- )} -
-
+ { + if (next === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(next); + }} + onSave={handleSaveRawSettings} + onRefresh={refreshAll} + />
From 20e48b3dc0e91fd0984ff1d059169aa6b64a84ce Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 23:42:17 +0700 Subject: [PATCH 53/94] refactor(dashboard): switch droid settings I/O to async fs - replace sync fs calls in compatible CLI JSON helper with fs.promises - make droid diagnostics/raw-settings service + routes async - update unit tests for async save/read paths --- src/web-server/routes/droid-routes.ts | 12 ++-- .../compatible-cli-json-file-service.ts | 59 ++++++++++++------- .../services/droid-dashboard-service.ts | 16 ++--- .../droid-dashboard-service.test.ts | 22 +++---- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts index 8cc81950..f18867d6 100644 --- a/src/web-server/routes/droid-routes.ts +++ b/src/web-server/routes/droid-routes.ts @@ -14,9 +14,9 @@ const router = Router(); * GET /api/droid/diagnostics * Dashboard-ready Droid installation + BYOK configuration diagnostics. */ -router.get('/diagnostics', (_req: Request, res: Response): void => { +router.get('/diagnostics', async (_req: Request, res: Response): Promise => { try { - res.json(getDroidDashboardDiagnostics()); + res.json(await getDroidDashboardDiagnostics()); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -26,9 +26,9 @@ router.get('/diagnostics', (_req: Request, res: Response): void => { * GET /api/droid/settings/raw * Raw ~/.factory/settings.json payload for editor. */ -router.get('/settings/raw', (_req: Request, res: Response): void => { +router.get('/settings/raw', async (_req: Request, res: Response): Promise => { try { - res.json(getDroidRawSettings()); + res.json(await getDroidRawSettings()); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -38,7 +38,7 @@ router.get('/settings/raw', (_req: Request, res: Response): void => { * PUT /api/droid/settings/raw * Save raw ~/.factory/settings.json payload from dashboard editor. */ -router.put('/settings/raw', (req: Request, res: Response): void => { +router.put('/settings/raw', async (req: Request, res: Response): Promise => { try { const { rawText, expectedMtime } = req.body ?? {}; @@ -54,7 +54,7 @@ router.put('/settings/raw', (req: Request, res: Response): void => { return; } - res.json(saveDroidRawSettings({ rawText, expectedMtime })); + res.json(await saveDroidRawSettings({ rawText, expectedMtime })); } catch (error) { if (error instanceof DroidRawSettingsValidationError) { res.status(400).json({ error: error.message }); diff --git a/src/web-server/services/compatible-cli-json-file-service.ts b/src/web-server/services/compatible-cli-json-file-service.ts index fe425cf8..eacf3a65 100644 --- a/src/web-server/services/compatible-cli-json-file-service.ts +++ b/src/web-server/services/compatible-cli-json-file-service.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; export interface JsonFileDiagnostics { @@ -55,12 +55,24 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -export function probeJsonObjectFile( +async function statPath(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +export async function probeJsonObjectFile( filePath: string, label: string, displayPath: string -): JsonFileProbe { - if (!fs.existsSync(filePath)) { +): Promise { + const stat = await statPath(filePath); + if (!stat) { return { diagnostics: { label, @@ -79,7 +91,6 @@ export function probeJsonObjectFile( }; } - const stat = fs.lstatSync(filePath); const diagnostics: JsonFileDiagnostics = { label, path: displayPath, @@ -104,7 +115,7 @@ export function probeJsonObjectFile( } try { - const rawText = fs.readFileSync(filePath, 'utf8'); + const rawText = await fs.readFile(filePath, 'utf8'); try { const parsed = JSON.parse(rawText); if (!isObject(parsed)) { @@ -140,9 +151,9 @@ export function parseJsonObjectText( return parsed; } -export function writeJsonObjectFileAtomic( +export async function writeJsonObjectFileAtomic( input: WriteJsonObjectFileInput -): WriteJsonObjectFileResult { +): Promise { const fileLabel = input.fileLabel || path.basename(input.filePath); const parsed = parseJsonObjectText(input.rawText, fileLabel); const targetPath = input.filePath; @@ -151,10 +162,11 @@ export function writeJsonObjectFileAtomic( const dirMode = input.dirMode ?? 0o700; const fileMode = input.fileMode ?? 0o600; - fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); + await fs.mkdir(targetDir, { recursive: true, mode: dirMode }); - if (fs.existsSync(targetPath)) { - const stat = fs.lstatSync(targetPath); + const targetStat = await statPath(targetPath); + if (targetStat) { + const stat = targetStat; if (stat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); } @@ -172,8 +184,9 @@ export function writeJsonObjectFileAtomic( let wroteTemp = false; try { - if (fs.existsSync(tempPath)) { - const tempStat = fs.lstatSync(tempPath); + const existingTempStat = await statPath(tempPath); + if (existingTempStat) { + const tempStat = existingTempStat; if (tempStat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); } @@ -182,10 +195,10 @@ export function writeJsonObjectFileAtomic( } } - fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); + await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); wroteTemp = true; - const tempStat = fs.lstatSync(tempPath); + const tempStat = await fs.lstat(tempPath); if (tempStat.isSymbolicLink()) { throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); } @@ -193,20 +206,26 @@ export function writeJsonObjectFileAtomic( throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); } - fs.renameSync(tempPath, targetPath); + await fs.rename(tempPath, targetPath); wroteTemp = false; try { - fs.chmodSync(targetPath, fileMode); + await fs.chmod(targetPath, fileMode); } catch { // Best-effort permission hardening. } - const stat = fs.statSync(targetPath); + const stat = await fs.stat(targetPath); return { mtime: stat.mtimeMs }; } finally { - if (wroteTemp && fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath); + if (wroteTemp) { + try { + await fs.unlink(tempPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } } } } diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 8a0e24a1..903c56ab 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -157,18 +157,18 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo }; } -export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { +export async function getDroidDashboardDiagnostics(): Promise { const paths = resolveDroidConfigPaths(); const binaryPath = detectDroidCli(); const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; - const settingsProbe = probeJsonObjectFile( + const settingsProbe = await probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath ); - const legacyConfigProbe = probeJsonObjectFile( + const legacyConfigProbe = await probeJsonObjectFile( paths.legacyConfigPath, 'Legacy config', paths.legacyConfigDisplayPath @@ -222,9 +222,9 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { }; } -export function getDroidRawSettings(): DroidRawSettingsResponse { +export async function getDroidRawSettings(): Promise { const paths = resolveDroidConfigPaths(); - const settingsProbe = probeJsonObjectFile( + const settingsProbe = await probeJsonObjectFile( paths.settingsPath, 'BYOK settings', paths.settingsDisplayPath @@ -241,13 +241,15 @@ export function getDroidRawSettings(): DroidRawSettingsResponse { }; } -export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { +export async function saveDroidRawSettings( + input: SaveDroidRawSettingsInput +): Promise { const paths = resolveDroidConfigPaths(); if (typeof input.rawText !== 'string') { throw new JsonFileValidationError('rawText must be a string.'); } - const saved = writeJsonObjectFileAtomic({ + const saved = await writeJsonObjectFileAtomic({ filePath: paths.settingsPath, rawText: input.rawText, expectedMtime: input.expectedMtime, diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index fcf2542a..c781785f 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -88,8 +88,8 @@ describe('droid-dashboard-service', () => { expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); }); - it('returns raw settings payload for missing settings file', () => { - const raw = getDroidRawSettings(); + it('returns raw settings payload for missing settings file', async () => { + const raw = await getDroidRawSettings(); expect(raw.exists).toBe(false); expect(raw.path).toBe('~/.factory/settings.json'); @@ -97,12 +97,12 @@ describe('droid-dashboard-service', () => { expect(raw.settings).toBeNull(); }); - it('returns parseError when settings.json is invalid JSON', () => { + it('returns parseError when settings.json is invalid JSON', async () => { const settingsDir = path.join(testRoot, '.factory'); fs.mkdirSync(settingsDir, { recursive: true }); fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); - const raw = getDroidRawSettings(); + const raw = await getDroidRawSettings(); expect(raw.exists).toBe(true); expect(raw.parseError).toBeString(); @@ -110,8 +110,8 @@ describe('droid-dashboard-service', () => { expect(raw.rawText).toContain('invalid-json'); }); - it('saves valid raw settings content', () => { - const result = saveDroidRawSettings({ + it('saves valid raw settings content', async () => { + const result = await saveDroidRawSettings({ rawText: JSON.stringify({ model: 'custom:test-model', customModels: [], @@ -126,23 +126,23 @@ describe('droid-dashboard-service', () => { expect(written.model).toBe('custom:test-model'); }); - it('rejects invalid JSON while saving raw settings', () => { - expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( + it('rejects invalid JSON while saving raw settings', async () => { + await expect(saveDroidRawSettings({ rawText: '{ invalid-json' })).rejects.toThrow( DroidRawSettingsValidationError ); }); - it('rejects stale writes with conflict error', () => { + it('rejects stale writes with conflict error', async () => { const settingsDir = path.join(testRoot, '.factory'); fs.mkdirSync(settingsDir, { recursive: true }); const settingsPath = path.join(settingsDir, 'settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); - expect(() => + await expect( saveDroidRawSettings({ rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), expectedMtime: 1, }) - ).toThrow(DroidRawSettingsConflictError); + ).rejects.toThrow(DroidRawSettingsConflictError); }); }); From 031dcd99c3978ca6d00a402a2cb2ce5e9d2306a2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:03:58 +0700 Subject: [PATCH 54/94] feat(dashboard): enrich droid docs and quick settings controls - add reusable compatible-CLI docs registry with external provider fact-check links - render docs links in Droid notes panel and keep future CLI extension points - add left-column quick controls for key settings.json fields (reasoning/autonomy/diff/automation) --- .../services/compatible-cli-docs-registry.ts | 105 +++++++++ .../services/compatible-cli-types.ts | 30 ++- .../services/droid-dashboard-service.ts | 18 +- .../droid-dashboard-service.test.ts | 14 ++ .../droid-settings-quick-controls-card.tsx | 214 ++++++++++++++++++ ui/src/hooks/use-droid.ts | 18 ++ ui/src/pages/droid.tsx | 148 +++++++++++- 7 files changed, 519 insertions(+), 28 deletions(-) create mode 100644 src/web-server/services/compatible-cli-docs-registry.ts create mode 100644 ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts new file mode 100644 index 00000000..551fa978 --- /dev/null +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -0,0 +1,105 @@ +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + +interface CompatibleCliDocsRegistryEntry { + cliId: string; + displayName: string; + docsReference: CompatibleCliDocsReference; +} + +const COMPATIBLE_CLI_DOCS_REGISTRY: Record = { + droid: { + cliId: 'droid', + displayName: 'Droid CLI', + docsReference: { + providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'], + settingsHierarchy: [ + 'project-level config', + 'user-level config', + 'home-level config', + 'CLI flags and env vars', + ], + notes: [ + 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Factory docs mention legacy support for ~/.factory/config.json', + 'Interactive model selection uses settings.model (custom:)', + 'droid exec supports --model for one-off execution mode', + ], + links: [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + category: 'overview', + source: 'factory', + description: 'Primary entry docs for setup, auth, and core CLI usage.', + }, + { + id: 'droid-byok-overview', + label: 'BYOK Overview', + url: 'https://docs.factory.ai/cli/byok/overview/', + category: 'byok', + source: 'factory', + description: 'BYOK model/provider shape, provider values, and migration notes.', + }, + { + id: 'droid-settings-reference', + label: 'settings.json Reference', + url: 'https://docs.factory.ai/cli/configuration/settings/', + category: 'configuration', + source: 'factory', + description: 'Supported settings keys, defaults, and allowed values.', + }, + ], + providerDocs: [ + { + provider: 'anthropic', + label: 'Anthropic Messages API', + apiFormat: 'Messages API', + url: 'https://docs.anthropic.com/en/api/messages', + }, + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + { + provider: 'generic-chat-completion-api', + label: 'OpenAI Chat Completions Spec', + apiFormat: 'Chat Completions API', + url: 'https://platform.openai.com/docs/api-reference/chat', + }, + ], + }, + }, +}; + +export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference { + const entry = COMPATIBLE_CLI_DOCS_REGISTRY[cliId]; + if (!entry) { + throw new Error(`Unsupported compatible CLI docs reference: ${cliId}`); + } + return entry.docsReference; +} diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts index bb9898e2..6d93c1ce 100644 --- a/src/web-server/services/compatible-cli-types.ts +++ b/src/web-server/services/compatible-cli-types.ts @@ -44,6 +44,30 @@ export interface DroidByokDiagnostics { customModels: DroidCustomModelDiagnostics[]; } +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + +export interface CompatibleCliDocsReference { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; +} + export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { @@ -52,11 +76,7 @@ export interface DroidDashboardDiagnostics { }; byok: DroidByokDiagnostics; warnings: string[]; - docsReference: { - providerValues: string[]; - settingsHierarchy: string[]; - notes: string[]; - }; + docsReference: CompatibleCliDocsReference; } export interface DroidRawSettingsResponse { diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index 903c56ab..ad960f21 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -14,6 +14,7 @@ import { probeJsonObjectFile, writeJsonObjectFileAtomic, } from './compatible-cli-json-file-service'; +import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry'; interface DroidConfigPaths { settingsPath: string; @@ -160,6 +161,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo export async function getDroidDashboardDiagnostics(): Promise { const paths = resolveDroidConfigPaths(); const binaryPath = detectDroidCli(); + const docsReference = getCompatibleCliDocsReference('droid'); const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; @@ -204,21 +206,7 @@ export async function getDroidDashboardDiagnostics(): Promise)', - 'droid exec supports --model for one-off execution mode', - ], - }, + docsReference, }; } diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index c781785f..50ebbe2a 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { DroidRawSettingsConflictError, DroidRawSettingsValidationError, + getDroidDashboardDiagnostics, getDroidRawSettings, maskApiKeyPreview, resolveDroidConfigPaths, @@ -110,6 +111,19 @@ describe('droid-dashboard-service', () => { expect(raw.rawText).toContain('invalid-json'); }); + it('includes structured docs links for fact-checking providers', async () => { + const diagnostics = await getDroidDashboardDiagnostics(); + + expect(diagnostics.docsReference.links.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0); + expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe( + true + ); + expect( + diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic') + ).toBe(true); + }); + it('saves valid raw settings content', async () => { const result = await saveDroidRawSettings({ rawText: JSON.stringify({ diff --git a/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx new file mode 100644 index 00000000..80f43ec7 --- /dev/null +++ b/ui/src/components/compatible-cli/droid-settings-quick-controls-card.tsx @@ -0,0 +1,214 @@ +import { SlidersHorizontal } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const UNSET_VALUE = '__unset__'; + +type DroidEnumSettingKey = 'reasoningEffort' | 'autonomyLevel' | 'diffMode'; +type DroidBooleanSettingKey = + | 'todoEnabled' + | 'todoAutoRefresh' + | 'autoCompactEnabled' + | 'soundEnabled'; +type DroidNumberSettingKey = 'maxTurns' | 'maxToolCalls' | 'autoCompactThreshold'; + +export interface DroidQuickSettingsValues { + reasoningEffort: string | null; + autonomyLevel: string | null; + diffMode: string | null; + maxTurns: number | null; + maxToolCalls: number | null; + autoCompactThreshold: number | null; + todoEnabled: boolean | null; + todoAutoRefresh: boolean | null; + autoCompactEnabled: boolean | null; + soundEnabled: boolean | null; +} + +interface DroidSettingsQuickControlsCardProps { + values: DroidQuickSettingsValues; + disabled: boolean; + disabledReason?: string | null; + onEnumSettingChange: (key: DroidEnumSettingKey, value: string | null) => void; + onBooleanSettingChange: (key: DroidBooleanSettingKey, value: boolean | null) => void; + onNumberSettingChange: (key: DroidNumberSettingKey, value: number | null) => void; +} + +const enumFieldConfig: Array<{ + key: DroidEnumSettingKey; + label: string; + description: string; + options: Array<{ value: string; label: string }>; +}> = [ + { + key: 'reasoningEffort', + label: 'Reasoning Effort', + description: 'none | medium | high | max', + options: [ + { value: 'none', label: 'none' }, + { value: 'medium', label: 'medium' }, + { value: 'high', label: 'high' }, + { value: 'max', label: 'max' }, + ], + }, + { + key: 'autonomyLevel', + label: 'Autonomy Level', + description: 'suggest | aggressive | full', + options: [ + { value: 'suggest', label: 'suggest' }, + { value: 'aggressive', label: 'aggressive' }, + { value: 'full', label: 'full' }, + ], + }, + { + key: 'diffMode', + label: 'Diff Mode', + description: 'auto | none | inline | split', + options: [ + { value: 'auto', label: 'auto' }, + { value: 'none', label: 'none' }, + { value: 'inline', label: 'inline' }, + { value: 'split', label: 'split' }, + ], + }, +]; + +const booleanFieldConfig: Array<{ + key: DroidBooleanSettingKey; + label: string; +}> = [ + { key: 'todoEnabled', label: 'Todo Enabled' }, + { key: 'todoAutoRefresh', label: 'Todo Auto Refresh' }, + { key: 'autoCompactEnabled', label: 'Auto Compact Enabled' }, + { key: 'soundEnabled', label: 'Sound Enabled' }, +]; + +const numberFieldConfig: Array<{ + key: DroidNumberSettingKey; + label: string; + min: number; + step: number; +}> = [ + { key: 'maxTurns', label: 'Max Turns', min: 1, step: 1 }, + { key: 'maxToolCalls', label: 'Max Tool Calls', min: 1, step: 1 }, + { key: 'autoCompactThreshold', label: 'Auto Compact Threshold', min: 1000, step: 1000 }, +]; + +function toBooleanSelectValue(value: boolean | null): string { + if (value === true) return 'true'; + if (value === false) return 'false'; + return UNSET_VALUE; +} + +function toBooleanValue(value: string): boolean | null { + if (value === 'true') return true; + if (value === 'false') return false; + return null; +} + +export function DroidSettingsQuickControlsCard({ + values, + disabled, + disabledReason, + onEnumSettingChange, + onBooleanSettingChange, + onNumberSettingChange, +}: DroidSettingsQuickControlsCardProps) { + return ( + + + + + Quick Settings + + settings.json + + + + + {disabledReason &&

{disabledReason}

} + +
+ {enumFieldConfig.map((field) => ( +
+

{field.label}

+ +

{field.description}

+
+ ))} + + {numberFieldConfig.map((field) => ( +
+

{field.label}

+ { + const nextRaw = event.target.value.trim(); + if (!nextRaw) { + onNumberSettingChange(field.key, null); + return; + } + const next = Number.parseInt(nextRaw, 10); + if (!Number.isFinite(next)) return; + onNumberSettingChange(field.key, Math.max(field.min, next)); + }} + className="h-8 text-xs" + disabled={disabled} + /> +
+ ))} + + {booleanFieldConfig.map((field) => ( +
+

{field.label}

+ +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index a4b2c062..7339829e 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -36,6 +36,22 @@ export interface DroidCustomModelDiagnostics { apiKeyPreview: string | null; } +export interface CompatibleCliDocLink { + id: string; + label: string; + url: string; + category: 'overview' | 'configuration' | 'byok' | 'reference'; + source: 'factory' | 'provider'; + description: string; +} + +export interface CompatibleCliProviderDocLink { + provider: string; + label: string; + apiFormat: string; + url: string; +} + export interface DroidDashboardDiagnostics { binary: DroidBinaryDiagnostics; files: { @@ -56,6 +72,8 @@ export interface DroidDashboardDiagnostics { providerValues: string[]; settingsHierarchy: string[]; notes: string[]; + links: CompatibleCliDocLink[]; + providerDocs: CompatibleCliProviderDocLink[]; }; } diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index d53256cb..796eb075 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -4,6 +4,7 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { AlertTriangle, CheckCircle2, + ExternalLink, Folder, GripVertical, Loader2, @@ -15,6 +16,10 @@ import { import { useDroid } from '@/hooks/use-droid'; import { isApiConflictError } from '@/lib/api-client'; import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { + DroidSettingsQuickControlsCard, + type DroidQuickSettingsValues, +} from '@/components/compatible-cli/droid-settings-quick-controls-card'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; @@ -33,18 +38,32 @@ function formatBytes(value: number | null | undefined): string { return `${(value / (1024 * 1024)).toFixed(2)} MB`; } -function validateJsonObject(text: string): { valid: true } | { valid: false; error: string } { +function parseJsonObjectText( + text: string +): { valid: true; value: Record } | { valid: false; error: string } { try { const parsed = JSON.parse(text); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return { valid: false, error: 'JSON root must be an object.' }; } - return { valid: true }; + return { valid: true, value: parsed as Record }; } catch (error) { return { valid: false, error: (error as Error).message }; } } +function asStringValue(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function asBooleanValue(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + function DetailRow({ label, value, @@ -79,6 +98,59 @@ export function DroidPage() { const rawBaseText = rawSettings?.rawText ?? '{}'; const rawEditorText = rawDraftText ?? rawBaseText; const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText; + const rawEditorParsed = parseJsonObjectText(rawEditorText); + const rawEditorValidation = rawEditorParsed.valid + ? { valid: true as const } + : { valid: false as const, error: rawEditorParsed.error }; + + const setRawEditorDraftText = (nextText: string) => { + if (nextText === rawBaseText) { + setRawDraftText(null); + return; + } + setRawDraftText(nextText); + }; + + const updateSettingsField = (key: string, value: unknown | null) => { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before using quick settings controls.'); + return; + } + + const nextSettings = { ...rawEditorParsed.value }; + if (value === null || value === undefined) { + delete nextSettings[key]; + } else { + nextSettings[key] = value; + } + setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + }; + + const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid + ? { + reasoningEffort: asStringValue(rawEditorParsed.value.reasoningEffort), + autonomyLevel: asStringValue(rawEditorParsed.value.autonomyLevel), + diffMode: asStringValue(rawEditorParsed.value.diffMode), + maxTurns: asNumberValue(rawEditorParsed.value.maxTurns), + maxToolCalls: asNumberValue(rawEditorParsed.value.maxToolCalls), + autoCompactThreshold: asNumberValue(rawEditorParsed.value.autoCompactThreshold), + todoEnabled: asBooleanValue(rawEditorParsed.value.todoEnabled), + todoAutoRefresh: asBooleanValue(rawEditorParsed.value.todoAutoRefresh), + autoCompactEnabled: asBooleanValue(rawEditorParsed.value.autoCompactEnabled), + soundEnabled: asBooleanValue(rawEditorParsed.value.soundEnabled), + } + : { + reasoningEffort: null, + autonomyLevel: null, + diffMode: null, + maxTurns: null, + maxToolCalls: null, + autoCompactThreshold: null, + todoEnabled: null, + todoAutoRefresh: null, + autoCompactEnabled: null, + soundEnabled: null, + }; const refreshAll = async () => { await Promise.all([refetchDiagnostics(), refetchRawSettings()]); @@ -112,7 +184,6 @@ export function DroidPage() { () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), [diagnostics?.byok.providerBreakdown] ); - const rawEditorValidation = validateJsonObject(rawEditorText); const renderOverview = () => { if (diagnosticsLoading) { @@ -202,6 +273,23 @@ export function DroidPage() { + { + updateSettingsField(key, value); + }} + onBooleanSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + onNumberSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + /> + @@ -255,6 +343,54 @@ export function DroidPage() {

))} +
+

+ Factory Docs +

+
+ {diagnostics.docsReference.links.map((link) => ( + +
+ {link.label} + +
+

{link.description}

+
+ ))} +
+
+ +
+

+ Provider Fact-Check Docs +

+
+ {diagnostics.docsReference.providerDocs.map((providerDoc) => ( + +
+ {providerDoc.label} + +
+

+ provider: {providerDoc.provider} | format: {providerDoc.apiFormat} +

+
+ ))} +
+
+

Provider values: {diagnostics.docsReference.providerValues.join(', ')}

@@ -358,11 +494,7 @@ export function DroidPage() { !rawEditorValidation.valid } onChange={(next) => { - if (next === rawBaseText) { - setRawDraftText(null); - return; - } - setRawDraftText(next); + setRawEditorDraftText(next); }} onSave={handleSaveRawSettings} onRefresh={refreshAll} From e7f3b3ffcf4d020ac20561e1d2c24d7aaa33e494 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:09:15 +0700 Subject: [PATCH 55/94] fix(dashboard): guard droid docs links for older API payloads - prevent blank page when docsReference.links/providerDocs are absent - fallback to empty lists so /droid stays usable during schema drift --- ui/src/pages/droid.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 796eb075..43867f39 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -203,6 +203,19 @@ export function DroidPage() { ); } + const docsReference = diagnostics.docsReference ?? { + notes: [], + links: [], + providerDocs: [], + providerValues: [], + settingsHierarchy: [], + }; + const docsNotes = docsReference.notes ?? []; + const docsLinks = docsReference.links ?? []; + const providerDocs = docsReference.providerDocs ?? []; + const providerValues = docsReference.providerValues ?? []; + const settingsHierarchy = docsReference.settingsHierarchy ?? []; + return (
@@ -337,7 +350,7 @@ export function DroidPage() { - {diagnostics.docsReference.notes.map((note) => ( + {docsNotes.map((note) => (

- {note}

@@ -348,7 +361,7 @@ export function DroidPage() { Factory Docs

- {diagnostics.docsReference.links.map((link) => ( + {docsLinks.map((link) => (
- {diagnostics.docsReference.providerDocs.map((providerDoc) => ( + {providerDocs.map((providerDoc) => (

- Provider values: {diagnostics.docsReference.providerValues.join(', ')} + Provider values: {providerValues.join(', ')}

- Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')} + Settings hierarchy: {settingsHierarchy.join(' -> ')}

From 1704b802ee4f32218d6c5707dd0dc2b617064dde Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 00:14:31 +0700 Subject: [PATCH 56/94] fix(dashboard): make droid docs links always clickable - render URL text as explicit anchors in docs cards - linkify raw URLs in docs notes - fallback to built-in Droid docs links when legacy API payload omits links --- ui/src/pages/droid.tsx | 98 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 43867f39..5bef6e73 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { @@ -26,6 +26,83 @@ import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +const DEFAULT_DROID_FACTORY_DOC_LINKS = [ + { + id: 'droid-cli-overview', + label: 'Droid CLI Overview', + url: 'https://docs.factory.ai/cli/', + description: 'Primary entry docs for setup, auth, and core CLI usage.', + }, + { + id: 'droid-byok-overview', + label: 'BYOK Overview', + url: 'https://docs.factory.ai/cli/byok/overview/', + description: 'BYOK model/provider shape, provider values, and migration notes.', + }, + { + id: 'droid-settings-reference', + label: 'settings.json Reference', + url: 'https://docs.factory.ai/cli/configuration/settings/', + description: 'Supported settings keys, defaults, and allowed values.', + }, +]; + +const DEFAULT_DROID_PROVIDER_DOC_LINKS = [ + { + provider: 'anthropic', + label: 'Anthropic Messages API', + apiFormat: 'Messages API', + url: 'https://docs.anthropic.com/en/api/messages', + }, + { + provider: 'openai', + label: 'OpenAI Responses API', + apiFormat: 'Responses API', + url: 'https://platform.openai.com/docs/api-reference/responses', + }, + { + provider: 'generic-chat-completion-api', + label: 'OpenAI Chat Completions Spec', + apiFormat: 'Chat Completions API', + url: 'https://platform.openai.com/docs/api-reference/chat', + }, +]; + +function renderTextWithLinks(text: string): ReactNode[] { + const urlPattern = /https?:\/\/[^\s)]+/g; + const nodes: ReactNode[] = []; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(text)) !== null) { + const [url] = match; + const index = match.index; + + if (index > cursor) { + nodes.push(text.slice(cursor, index)); + } + + nodes.push( +
+ {url} + + ); + cursor = index + url.length; + } + + if (cursor < text.length) { + nodes.push(text.slice(cursor)); + } + + return nodes.length > 0 ? nodes : [text]; +} + function formatTimestamp(value: number | null | undefined): string { if (!value || !Number.isFinite(value)) return 'N/A'; return new Date(value).toLocaleString(); @@ -211,8 +288,11 @@ export function DroidPage() { settingsHierarchy: [], }; const docsNotes = docsReference.notes ?? []; - const docsLinks = docsReference.links ?? []; - const providerDocs = docsReference.providerDocs ?? []; + const docsLinksRaw = docsReference.links ?? []; + const providerDocsRaw = docsReference.providerDocs ?? []; + const docsLinks = docsLinksRaw.length > 0 ? docsLinksRaw : DEFAULT_DROID_FACTORY_DOC_LINKS; + const providerDocs = + providerDocsRaw.length > 0 ? providerDocsRaw : DEFAULT_DROID_PROVIDER_DOC_LINKS; const providerValues = docsReference.providerValues ?? []; const settingsHierarchy = docsReference.settingsHierarchy ?? []; @@ -350,9 +430,9 @@ export function DroidPage() { - {docsNotes.map((note) => ( -

- - {note} + {docsNotes.map((note, index) => ( +

+ - {renderTextWithLinks(note)}

))} @@ -374,6 +454,9 @@ export function DroidPage() {

{link.description}

+

+ {link.url} +

))}
@@ -399,6 +482,9 @@ export function DroidPage() {

provider: {providerDoc.provider} | format: {providerDoc.apiFormat}

+

+ {providerDoc.url} +

))}
From 1f3db584eb2aabe68da006de686fbaa226772864 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 18:02:54 +0000 Subject: [PATCH 57/94] chore(release): 7.50.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a612368e..2f20b3e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.2", + "version": "7.50.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From eedb53b49e41b044f570a7273c6b6a9231cd75d2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 12:27:50 +0700 Subject: [PATCH 58/94] feat(droid): sync reasoning effort across CLI and dashboard --- src/ccs.ts | 46 ++- src/targets/droid-adapter.ts | 1 + src/targets/droid-config-manager.ts | 129 +++++- src/targets/droid-reasoning-runtime.ts | 56 +++ src/targets/target-adapter.ts | 6 + .../services/compatible-cli-docs-registry.ts | 2 + .../services/droid-dashboard-service.ts | 46 ++- .../unit/targets/droid-config-manager.test.ts | 73 ++++ .../targets/droid-reasoning-runtime.test.ts | 37 ++ tests/unit/targets/target-registry.test.ts | 25 ++ .../droid-dashboard-service.test.ts | 72 ++++ .../droid-byok-reasoning-controls-card.tsx | 134 +++++++ ui/src/lib/droid-byok-custom-models.ts | 377 ++++++++++++++++++ ui/src/pages/droid.tsx | 60 ++- .../ui/lib/droid-byok-custom-models.test.ts | 182 +++++++++ 15 files changed, 1228 insertions(+), 18 deletions(-) create mode 100644 src/targets/droid-reasoning-runtime.ts create mode 100644 tests/unit/targets/droid-reasoning-runtime.test.ts create mode 100644 ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx create mode 100644 ui/src/lib/droid-byok-custom-models.ts create mode 100644 ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 245d4bf9..f3b24d56 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -58,6 +58,10 @@ import { type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; +import { + DroidReasoningFlagError, + resolveDroidReasoningRuntime, +} from './targets/droid-reasoning-runtime'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -711,6 +715,32 @@ async function main(): Promise { } } + let targetRemainingArgs = remainingArgs; + let droidReasoningOverride: string | number | undefined; + if (resolvedTarget === 'droid') { + try { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + droidReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort medium|high|xhigh'); + process.exit(1); + } + throw error; + } + } + // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. @@ -772,14 +802,13 @@ async function main(): Promise { '--remote-only', '--no-fallback', '--allow-self-signed', - '--thinking', - '--effort', '--1m', '--no-1m', ]; const providedUnsupportedFlag = unsupportedCliproxyFlags.find( (flag) => - remainingArgs.includes(flag) || remainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) ); if (providedUnsupportedFlag) { console.error( @@ -817,7 +846,7 @@ async function main(): Promise { const ensureServiceResult = await ensureCliproxyService( cliproxyPort, - remainingArgs.includes('--verbose') || remainingArgs.includes('-v') + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') ); if (!ensureServiceResult.started) { console.error( @@ -847,6 +876,7 @@ async function main(): Promise { baseUrl: envVars['ANTHROPIC_BASE_URL'], model: envVars['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, envVars, }; @@ -864,7 +894,7 @@ async function main(): Promise { } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -1020,9 +1050,10 @@ async function main(): Promise { baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], model: settingsEnv['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, }; await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -1091,6 +1122,7 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'], model: process.env['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, }; if (!creds.baseUrl || !creds.apiKey) { console.error( @@ -1102,7 +1134,7 @@ async function main(): Promise { process.exit(1); } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', remainingArgs); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 2deeb1d7..e42ed2d0 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -55,6 +55,7 @@ export class DroidAdapter implements TargetAdapter { baseUrl: creds.baseUrl, apiKey: creds.apiKey, provider, + reasoningOverride: creds.reasoningOverride, }); if (!modelRef.selector) { throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`); diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index ac3c877a..29a3943e 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -41,6 +41,7 @@ export interface DroidCustomModel { apiKey: string; provider: 'anthropic' | 'openai' | 'generic-chat-completion-api'; maxOutputTokens?: number; + reasoningOverride?: string | number; } export interface DroidManagedModelRef { @@ -64,13 +65,31 @@ interface DroidCustomModelEntry { apiKey: string; provider: string; maxOutputTokens?: number; + extraArgs?: Record; + extra_args?: Record; /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ + [key: string]: unknown; } +const DROID_REASONING_OFF_VALUES = new Set(['off', 'none', 'disabled', '0']); +const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record = { + minimal: 4000, + low: 4000, + medium: 12000, + high: 30000, + max: 50000, + xhigh: 64000, + auto: 30000, +}; + function isSupportedProvider(value: string): value is DroidCustomModel['provider'] { return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; } +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry { if (!value || typeof value !== 'object') return false; const record = value as Record; @@ -106,6 +125,100 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null { return isDroidCustomModelEntry(value) ? value : null; } +function isReasoningOffValue(value: string | number): boolean { + if (typeof value === 'number') return value <= 0; + const normalized = value.trim().toLowerCase(); + return DROID_REASONING_OFF_VALUES.has(normalized); +} + +function toAnthropicBudget(value: string | number): number { + if (typeof value === 'number') { + return Math.max(1024, Math.floor(value)); + } + + const normalized = value.trim().toLowerCase(); + if (/^\d+$/.test(normalized)) { + return Math.max(1024, Number.parseInt(normalized, 10)); + } + + return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; +} + +function toReasoningEffort(value: string | number): string { + if (typeof value === 'number') { + if (value <= 4000) return 'low'; + if (value <= 12000) return 'medium'; + if (value <= 30000) return 'high'; + if (value <= 50000) return 'max'; + return 'xhigh'; + } + + const normalized = value.trim().toLowerCase(); + if (!normalized) return 'high'; + return normalized; +} + +function applyReasoningOverride( + entry: DroidCustomModelEntry, + provider: DroidCustomModel['provider'], + reasoningOverride: string | number +): void { + const extraArgsKey: 'extraArgs' | 'extra_args' = Object.prototype.hasOwnProperty.call( + entry, + 'extra_args' + ) + ? 'extra_args' + : 'extraArgs'; + const currentExtraArgs = entry[extraArgsKey]; + const extraArgs = isObject(currentExtraArgs) ? { ...currentExtraArgs } : {}; + + // Normalize legacy aliases before writing provider-specific shape. + delete extraArgs.reasoningEffort; + + if (provider === 'anthropic') { + delete extraArgs.reasoning; + delete extraArgs.reasoning_effort; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.thinking; + } else { + const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + thinking.type = 'enabled'; + thinking.budget_tokens = toAnthropicBudget(reasoningOverride); + delete thinking.budgetTokens; + extraArgs.thinking = thinking; + } + } else if (provider === 'openai') { + delete extraArgs.reasoning_effort; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning; + } else { + const reasoning = isObject(extraArgs.reasoning) ? { ...extraArgs.reasoning } : {}; + reasoning.effort = toReasoningEffort(reasoningOverride); + extraArgs.reasoning = reasoning; + } + } else { + delete extraArgs.reasoning; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning_effort; + } else { + extraArgs.reasoning_effort = toReasoningEffort(reasoningOverride); + } + } + + if (Object.keys(extraArgs).length === 0) { + delete entry.extraArgs; + delete entry.extra_args; + return; + } + + entry[extraArgsKey] = extraArgs; +} + function buildSelectorAlias(displayName: string, index: number): string { const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-'); return `${normalizedDisplayName}-${index}`; @@ -331,16 +444,22 @@ export async function upsertCcsModel( const settings = readDroidSettings(); settings.customModels = normalizeCustomModels(settings.customModels); - const entry: DroidCustomModelEntry = { - ...model, - displayName: `CCS ${profile}`, - }; - // Find existing current or legacy entry for this profile. const idx = settings.customModels.findIndex( (m) => parseManagedProfile(m.displayName) === profile ); + const { reasoningOverride, ...modelWithoutReasoning } = model; + const existingEntry = idx >= 0 ? settings.customModels[idx] : undefined; + const entry: DroidCustomModelEntry = { + ...(existingEntry ?? {}), + ...modelWithoutReasoning, + displayName: `CCS ${profile}`, + }; + if (reasoningOverride !== undefined) { + applyReasoningOverride(entry, model.provider, reasoningOverride); + } + if (idx >= 0) { settings.customModels[idx] = entry; } else { diff --git a/src/targets/droid-reasoning-runtime.ts b/src/targets/droid-reasoning-runtime.ts new file mode 100644 index 00000000..c257e329 --- /dev/null +++ b/src/targets/droid-reasoning-runtime.ts @@ -0,0 +1,56 @@ +import { parseThinkingOverride, type ThinkingFlag } from '../cliproxy/executor/thinking-arg-parser'; +import { resolveRuntimeThinkingOverride } from '../cliproxy/executor/thinking-override-resolver'; + +export class DroidReasoningFlagError extends Error { + constructor( + message: string, + public readonly flag: ThinkingFlag + ) { + super(message); + this.name = 'DroidReasoningFlagError'; + } +} + +export interface DroidReasoningRuntime { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; + sourceFlag: ThinkingFlag | undefined; + sourceDisplay: string | undefined; + duplicateDisplays: string[]; +} + +function stripReasoningFlags(args: string[]): string[] { + return args.filter((arg, idx) => { + if (arg === '--thinking' || arg === '--effort') return false; + if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--effort=')) return false; + if (args[idx - 1] === '--thinking' || args[idx - 1] === '--effort') return false; + return true; + }); +} + +export function resolveDroidReasoningRuntime( + args: string[], + envThinkingValue: string | undefined +): DroidReasoningRuntime { + const parseResult = parseThinkingOverride(args); + if (parseResult.error) { + throw new DroidReasoningFlagError( + `${parseResult.error.flag} requires a value`, + parseResult.error.flag + ); + } + + const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( + parseResult.value, + envThinkingValue + ); + + return { + argsWithoutReasoningFlags: stripReasoningFlags(args), + reasoningOverride: thinkingOverride, + sourceFlag: thinkingSource === 'flag' ? parseResult.sourceFlag : undefined, + sourceDisplay: parseResult.sourceDisplay, + duplicateDisplays: parseResult.duplicateDisplays, + }; +} diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index 522ee905..ffe4b890 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -23,6 +23,12 @@ export interface TargetCredentials { apiKey: string; model?: string; provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + /** + * Runtime reasoning/thinking override resolved from CCS flags/env + * (e.g. --thinking high, --effort xhigh, CCS_THINKING=medium). + * Targets may ignore this when unsupported. + */ + reasoningOverride?: string | number; /** Additional env vars from profile resolution (websearch, hooks, etc.) */ envVars?: NodeJS.ProcessEnv; } diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index 551fa978..ac3fc862 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -42,8 +42,10 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record)', + 'Provider-specific reasoning keys in extraArgs: generic-chat-completion-api => reasoning_effort, openai => reasoning.effort, anthropic => thinking.{type,budget_tokens}', 'droid exec supports --model for one-off execution mode', ], links: [ diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index ad960f21..e061e022 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -42,10 +42,18 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function asObject(value: unknown): Record | null { + return isObject(value) ? value : null; +} + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + function parseHost(value: string): string | null { try { return new URL(value).host || null; @@ -118,11 +126,11 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo continue; } - const displayName = asString(item.displayName); + const displayName = asString(item.displayName) ?? asString(item.model_display_name); const model = asString(item.model); - const baseUrl = asString(item.baseUrl); + const baseUrl = asString(item.baseUrl) ?? asString(item.base_url); const providerRaw = asString(item.provider); - const apiKey = asString(item.apiKey); + const apiKey = asString(item.apiKey) ?? asString(item.api_key); if (!displayName || !model || !baseUrl || !providerRaw) { invalidModelEntryCount += 1; @@ -138,7 +146,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo provider, baseUrl, host: parseHost(baseUrl), - maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + maxOutputTokens: asNumber(item.maxOutputTokens) ?? asNumber(item.max_tokens), isCcsManaged: isCcsManagedDisplayName(displayName), apiKeyState: apiKey ? 'set' : 'missing', apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, @@ -158,6 +166,25 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo }; } +function resolveCustomModelsValue(settings: Record | null): unknown { + if (!settings) return undefined; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return modern; + + const legacy = settings.custom_models; + if (Array.isArray(legacy) || isObject(legacy)) return legacy; + return undefined; +} + +function usesLegacyCustomModelsKey(settings: Record | null): boolean { + if (!settings) return false; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return false; + + const legacy = settings.custom_models; + return Array.isArray(legacy) || isObject(legacy); +} + export async function getDroidDashboardDiagnostics(): Promise { const paths = resolveDroidConfigPaths(); const binaryPath = detectDroidCli(); @@ -176,7 +203,11 @@ export async function getDroidDashboardDiagnostics(): Promise { expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318'); }); + it('should persist generic provider reasoning_effort from override', async () => { + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'high', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.reasoning_effort).toBe('high'); + expect(settings.customModels[0].extraArgs?.reasoning).toBeUndefined(); + expect(settings.customModels[0].extraArgs?.thinking).toBeUndefined(); + }); + + it('should persist openai provider reasoning.effort from --effort alias override', async () => { + await upsertCcsModel('codex', { + model: 'gpt-5.2', + displayName: 'CCS codex', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'openai-key', + provider: 'openai', + reasoningOverride: 'xhigh', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.reasoning?.effort).toBe('xhigh'); + expect(settings.customModels[0].extraArgs?.reasoning_effort).toBeUndefined(); + }); + + it('should persist anthropic thinking budget from numeric override', async () => { + await upsertCcsModel('agy', { + model: 'claude-opus-4-5-thinking', + displayName: 'CCS agy', + baseUrl: 'https://api.anthropic.com', + apiKey: 'anthropic-key', + provider: 'anthropic', + reasoningOverride: 40960, + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('enabled'); + expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960); + }); + + it('should clear prior reasoning config when override disables thinking', async () => { + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'high', + }); + + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'off', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs).toBeUndefined(); + }); + it('should preserve user entries', async () => { // Create existing settings with user's own custom model const factoryDir = path.join(tmpDir, '.factory'); diff --git a/tests/unit/targets/droid-reasoning-runtime.test.ts b/tests/unit/targets/droid-reasoning-runtime.test.ts new file mode 100644 index 00000000..445f4949 --- /dev/null +++ b/tests/unit/targets/droid-reasoning-runtime.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { + DroidReasoningFlagError, + resolveDroidReasoningRuntime, +} from '../../../src/targets/droid-reasoning-runtime'; + +describe('droid-reasoning-runtime', () => { + it('extracts --thinking and strips CCS reasoning flags from args', () => { + const runtime = resolveDroidReasoningRuntime(['--thinking', 'high', '--verbose'], undefined); + + expect(runtime.reasoningOverride).toBe('high'); + expect(runtime.sourceFlag).toBe('--thinking'); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']); + }); + + it('extracts --effort alias and strips inline value', () => { + const runtime = resolveDroidReasoningRuntime(['--effort=xhigh', '--help'], undefined); + + expect(runtime.reasoningOverride).toBe('xhigh'); + expect(runtime.sourceFlag).toBe('--effort'); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--help']); + }); + + it('uses CCS_THINKING env fallback when no flag is provided', () => { + const runtime = resolveDroidReasoningRuntime(['--verbose'], 'medium'); + + expect(runtime.reasoningOverride).toBe('medium'); + expect(runtime.sourceFlag).toBeUndefined(); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']); + }); + + it('throws on missing reasoning flag value', () => { + expect(() => resolveDroidReasoningRuntime(['--thinking'], undefined)).toThrow( + DroidReasoningFlagError + ); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index c20b1bda..046036b6 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -191,6 +191,31 @@ describe('DroidAdapter', () => { } }); + it('prepareCredentials should persist reasoning override into Droid extraArgs', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-reasoning-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + + try { + await adapter.prepareCredentials({ + profile: 'codex', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'dummy-key', + model: 'gpt-5.2', + provider: 'openai', + reasoningOverride: 'high', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels?.[0]?.extraArgs?.reasoning?.effort).toBe('high'); + } finally { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('buildArgs should use selector returned from Droid settings entry', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-')); const originalCcsHome = process.env.CCS_HOME; diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index 50ebbe2a..2396422e 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -89,6 +89,26 @@ describe('droid-dashboard-service', () => { expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); }); + it('supports legacy snake_case model fields in summaries', () => { + const summary = summarizeDroidCustomModels([ + { + model_display_name: 'Kimi K2 Thinking Nvidia', + model: 'moonshotai/kimi-k2-thinking', + base_url: 'https://integrate.api.nvidia.com/v1', + api_key: 'legacy-token-1234', + provider: 'generic-chat-completion-api', + max_tokens: 220000, + }, + ]); + + expect(summary.customModelCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(0); + expect(summary.providerBreakdown['generic-chat-completion-api']).toBe(1); + expect(summary.customModels[0].displayName).toBe('Kimi K2 Thinking Nvidia'); + expect(summary.customModels[0].maxOutputTokens).toBe(220000); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + it('returns raw settings payload for missing settings file', async () => { const raw = await getDroidRawSettings(); @@ -124,6 +144,58 @@ describe('droid-dashboard-service', () => { ).toBe(true); }); + it('falls back to legacy config custom_models when settings customModels is absent', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ model: 'custom:legacy' })); + fs.writeFileSync( + path.join(settingsDir, 'config.json'), + JSON.stringify({ + custom_models: [ + { + model_display_name: 'Legacy OpenAI', + model: 'gpt-5.2', + base_url: 'https://api.openai.com/v1', + api_key: 'legacy-openai-1234', + provider: 'openai', + }, + ], + }) + ); + + const diagnostics = await getDroidDashboardDiagnostics(); + + expect(diagnostics.byok.customModelCount).toBe(1); + expect(diagnostics.byok.customModels[0].displayName).toBe('Legacy OpenAI'); + expect(diagnostics.byok.customModels[0].provider).toBe('openai'); + }); + + it('warns when settings.json uses legacy custom_models key', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'settings.json'), + JSON.stringify({ + custom_models: [ + { + model_display_name: 'Legacy Generic', + model: 'glm-4.7', + base_url: 'https://api.z.ai/api/coding/paas/v4', + api_key: 'legacy-zai-1234', + provider: 'generic-chat-completion-api', + }, + ], + }) + ); + + const diagnostics = await getDroidDashboardDiagnostics(); + + expect( + diagnostics.warnings.some((warning) => warning.includes('legacy "custom_models" key')) + ).toBe(true); + expect(diagnostics.byok.customModelCount).toBe(1); + }); + it('saves valid raw settings content', async () => { const result = await saveDroidRawSettings({ rawText: JSON.stringify({ diff --git a/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx new file mode 100644 index 00000000..c65b9390 --- /dev/null +++ b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx @@ -0,0 +1,134 @@ +import { BrainCircuit } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + DROID_REASONING_EFFORT_OPTIONS, + type DroidByokModelView, +} from '@/lib/droid-byok-custom-models'; + +const UNSET_VALUE = '__unset__'; + +interface DroidByokReasoningControlsCardProps { + models: DroidByokModelView[]; + disabled: boolean; + disabledReason?: string | null; + onEffortChange: (modelId: string, effort: string | null) => void; + onAnthropicBudgetChange: (modelId: string, budgetTokens: number | null) => void; +} + +function getReasoningPathHint(model: DroidByokModelView): string { + if (model.providerKind === 'openai') return 'Writes: extraArgs.reasoning.effort'; + if (model.providerKind === 'anthropic') { + return 'Writes: extraArgs.thinking.{type,budget_tokens}'; + } + return 'Writes: extraArgs.reasoning_effort'; +} + +export function DroidByokReasoningControlsCard({ + models, + disabled, + disabledReason, + onEffortChange, + onAnthropicBudgetChange, +}: DroidByokReasoningControlsCardProps) { + return ( + + + + + BYOK Reasoning / Thinking + + customModels + + + + + {disabledReason &&

{disabledReason}

} + + {models.length === 0 ? ( +

+ No BYOK custom models found in settings.json (`customModels` or `custom_models`). +

+ ) : ( +
+ {models.map((model) => ( +
+
+
+

{model.displayName}

+

+ {model.model || '(missing model id)'} +

+
+ + {model.provider} + +
+ +
+
+

Reasoning Effort

+ +
+ + {model.providerKind === 'anthropic' && ( +
+

Thinking Budget Tokens

+ { + const raw = event.target.value.trim(); + if (!raw) { + onAnthropicBudgetChange(model.id, null); + return; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return; + onAnthropicBudgetChange(model.id, Math.max(1024, parsed)); + }} + /> +
+ )} +
+ +

{getReasoningPathHint(model)}

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/src/lib/droid-byok-custom-models.ts b/ui/src/lib/droid-byok-custom-models.ts new file mode 100644 index 00000000..c454dcdb --- /dev/null +++ b/ui/src/lib/droid-byok-custom-models.ts @@ -0,0 +1,377 @@ +type DroidCustomModelRootKey = 'customModels' | 'custom_models'; +type DroidCustomModelLocationType = 'array' | 'object'; + +export type DroidByokProviderKind = + | 'anthropic' + | 'openai' + | 'generic-chat-completion-api' + | 'unknown'; + +const DROID_CUSTOM_MODEL_ROOT_KEYS: DroidCustomModelRootKey[] = ['customModels', 'custom_models']; +const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record = { + low: 4000, + medium: 12000, + high: 30000, + max: 50000, + xhigh: 64000, +}; + +export const DROID_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'max', 'xhigh'] as const; + +export interface DroidByokModelView { + id: string; + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + displayName: string; + model: string; + provider: string; + providerKind: DroidByokProviderKind; + effort: string | null; + anthropicBudgetTokens: number | null; +} + +interface DroidByokModelLookup { + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; +} + +interface ExtractedReasoning { + effort: string | null; + anthropicBudgetTokens: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function normalizeProviderKind(provider: string | null): DroidByokProviderKind { + if (!provider) return 'unknown'; + const normalized = provider.toLowerCase(); + if (normalized === 'anthropic') return 'anthropic'; + if (normalized === 'openai') return 'openai'; + if (normalized === 'generic-chat-completion-api') return 'generic-chat-completion-api'; + return 'unknown'; +} + +function buildModelId( + rootKey: DroidCustomModelRootKey, + locationType: DroidCustomModelLocationType, + locationKey: number | string +): string { + return `${rootKey}:${locationType}:${encodeURIComponent(String(locationKey))}`; +} + +function parseModelId(modelId: string): DroidByokModelLookup | null { + const firstSeparator = modelId.indexOf(':'); + const secondSeparator = modelId.indexOf(':', firstSeparator + 1); + if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1) return null; + + const rootKey = modelId.slice(0, firstSeparator); + const locationType = modelId.slice(firstSeparator + 1, secondSeparator); + const encodedLocation = modelId.slice(secondSeparator + 1); + + if (rootKey !== 'customModels' && rootKey !== 'custom_models') return null; + if (locationType !== 'array' && locationType !== 'object') return null; + + const locationValue = decodeURIComponent(encodedLocation); + if (locationType === 'array') { + const parsedIndex = Number.parseInt(locationValue, 10); + if (!Number.isInteger(parsedIndex) || parsedIndex < 0) return null; + return { + rootKey, + locationType, + locationKey: parsedIndex, + }; + } + + return { + rootKey, + locationType, + locationKey: locationValue, + }; +} + +function inferEffortFromAnthropicBudget(budgetTokens: number | null): string | null { + if (!budgetTokens || budgetTokens <= 0) return null; + if (budgetTokens <= 4000) return 'low'; + if (budgetTokens <= 12000) return 'medium'; + if (budgetTokens <= 30000) return 'high'; + if (budgetTokens <= 50000) return 'max'; + return 'xhigh'; +} + +function resolveExtraArgsKey(modelEntry: Record): 'extraArgs' | 'extra_args' { + if (Object.prototype.hasOwnProperty.call(modelEntry, 'extraArgs')) { + return 'extraArgs'; + } + if (Object.prototype.hasOwnProperty.call(modelEntry, 'extra_args')) { + return 'extra_args'; + } + return 'extraArgs'; +} + +function cloneSettings(settings: Record): Record { + return JSON.parse(JSON.stringify(settings)) as Record; +} + +function listEntryRecords(settings: Record): Array<{ + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + entry: Record; +}> { + const rows: Array<{ + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + entry: Record; + }> = []; + + for (const rootKey of DROID_CUSTOM_MODEL_ROOT_KEYS) { + const container = settings[rootKey]; + if (Array.isArray(container)) { + container.forEach((item, index) => { + if (isRecord(item)) { + rows.push({ rootKey, locationType: 'array', locationKey: index, entry: item }); + } + }); + continue; + } + + if (!isRecord(container)) continue; + + for (const [objectKey, item] of Object.entries(container)) { + if (isRecord(item)) { + rows.push({ rootKey, locationType: 'object', locationKey: objectKey, entry: item }); + } + } + } + + return rows; +} + +function lookupEntryById( + settings: Record, + modelId: string +): { entry: Record; providerKind: DroidByokProviderKind } | null { + const parsed = parseModelId(modelId); + if (!parsed) return null; + + const container = settings[parsed.rootKey]; + + if (parsed.locationType === 'array') { + if (!Array.isArray(container)) return null; + const item = container[parsed.locationKey as number]; + if (!isRecord(item)) return null; + + const provider = asNonEmptyString(item.provider); + return { entry: item, providerKind: normalizeProviderKind(provider) }; + } + + if (!isRecord(container)) return null; + const item = container[parsed.locationKey as string]; + if (!isRecord(item)) return null; + + const provider = asNonEmptyString(item.provider); + return { entry: item, providerKind: normalizeProviderKind(provider) }; +} + +function extractReasoningDetails( + providerKind: DroidByokProviderKind, + modelEntry: Record +): ExtractedReasoning { + const extraArgsCandidate = modelEntry.extraArgs ?? modelEntry.extra_args; + const extraArgs = isRecord(extraArgsCandidate) ? extraArgsCandidate : null; + if (!extraArgs) { + return { effort: null, anthropicBudgetTokens: null }; + } + + const flatReasoningEffort = + asNonEmptyString(extraArgs.reasoning_effort) ?? asNonEmptyString(extraArgs.reasoningEffort); + const reasoningConfig = isRecord(extraArgs.reasoning) ? extraArgs.reasoning : null; + const nestedReasoningEffort = reasoningConfig ? asNonEmptyString(reasoningConfig.effort) : null; + const thinkingConfig = isRecord(extraArgs.thinking) ? extraArgs.thinking : null; + const thinkingType = thinkingConfig ? asNonEmptyString(thinkingConfig.type) : null; + const anthropicBudgetTokens = thinkingConfig + ? (asFiniteNumber(thinkingConfig.budget_tokens) ?? asFiniteNumber(thinkingConfig.budgetTokens)) + : null; + + if (providerKind === 'openai') { + return { + effort: nestedReasoningEffort ?? flatReasoningEffort, + anthropicBudgetTokens: null, + }; + } + + if (providerKind === 'anthropic') { + if (thinkingType === 'enabled') { + return { + effort: inferEffortFromAnthropicBudget(anthropicBudgetTokens) ?? 'high', + anthropicBudgetTokens, + }; + } + return { + effort: nestedReasoningEffort ?? flatReasoningEffort, + anthropicBudgetTokens, + }; + } + + return { + effort: flatReasoningEffort ?? nestedReasoningEffort, + anthropicBudgetTokens: null, + }; +} + +function sanitizeEffortInput(value: string | null): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === 'default' || normalized === 'unset') return null; + if (normalized === 'off' || normalized === 'none' || normalized === 'disabled') return null; + return normalized; +} + +function ensureExtraArgs(entry: Record): { + extraArgsKey: 'extraArgs' | 'extra_args'; + extraArgs: Record; +} { + const extraArgsKey = resolveExtraArgsKey(entry); + const currentExtraArgs = entry[extraArgsKey]; + const nextExtraArgs = isRecord(currentExtraArgs) ? { ...currentExtraArgs } : {}; + return { extraArgsKey, extraArgs: nextExtraArgs }; +} + +function commitExtraArgs( + entry: Record, + extraArgsKey: 'extraArgs' | 'extra_args', + extraArgs: Record +): void { + if (Object.keys(extraArgs).length === 0) { + delete entry[extraArgsKey]; + return; + } + entry[extraArgsKey] = extraArgs; +} + +export function extractDroidByokModels(settings: Record): DroidByokModelView[] { + return listEntryRecords(settings).map(({ rootKey, locationType, locationKey, entry }) => { + const displayName = + asNonEmptyString(entry.displayName) ?? + asNonEmptyString(entry.model_display_name) ?? + 'Unnamed model'; + const model = asNonEmptyString(entry.model) ?? ''; + const provider = asNonEmptyString(entry.provider) ?? 'unknown'; + const providerKind = normalizeProviderKind(provider); + const reasoning = extractReasoningDetails(providerKind, entry); + + return { + id: buildModelId(rootKey, locationType, locationKey), + rootKey, + locationType, + locationKey, + displayName, + model, + provider, + providerKind, + effort: reasoning.effort, + anthropicBudgetTokens: reasoning.anthropicBudgetTokens, + }; + }); +} + +export function applyReasoningEffortToDroidByokModel( + settings: Record, + modelId: string, + effort: string | null +): Record | null { + const nextSettings = cloneSettings(settings); + const target = lookupEntryById(nextSettings, modelId); + if (!target) return null; + + const normalizedEffort = sanitizeEffortInput(effort); + const { extraArgsKey, extraArgs } = ensureExtraArgs(target.entry); + + if (target.providerKind === 'openai') { + delete extraArgs.reasoning_effort; + delete extraArgs.reasoningEffort; + + if (!normalizedEffort) { + delete extraArgs.reasoning; + } else { + const existingReasoning = isRecord(extraArgs.reasoning) ? extraArgs.reasoning : {}; + extraArgs.reasoning = { + ...existingReasoning, + effort: normalizedEffort, + }; + } + } else if (target.providerKind === 'anthropic') { + delete extraArgs.reasoning_effort; + delete extraArgs.reasoningEffort; + delete extraArgs.reasoning; + + if (!normalizedEffort) { + delete extraArgs.thinking; + } else { + const existingThinking = isRecord(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + const existingBudget = + asFiniteNumber(existingThinking.budget_tokens) ?? + asFiniteNumber(existingThinking.budgetTokens); + + delete existingThinking.budgetTokens; + extraArgs.thinking = { + ...existingThinking, + type: 'enabled', + budget_tokens: + existingBudget ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalizedEffort] ?? 30000, + }; + } + } else { + delete extraArgs.reasoning; + delete extraArgs.reasoningEffort; + + if (!normalizedEffort) { + delete extraArgs.reasoning_effort; + } else { + extraArgs.reasoning_effort = normalizedEffort; + } + } + + commitExtraArgs(target.entry, extraArgsKey, extraArgs); + return nextSettings; +} + +export function applyAnthropicBudgetTokensToDroidByokModel( + settings: Record, + modelId: string, + budgetTokens: number | null +): Record | null { + const nextSettings = cloneSettings(settings); + const target = lookupEntryById(nextSettings, modelId); + if (!target || target.providerKind !== 'anthropic') return null; + + const { extraArgsKey, extraArgs } = ensureExtraArgs(target.entry); + const thinking = isRecord(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + thinking.type = 'enabled'; + + if (budgetTokens === null) { + delete thinking.budget_tokens; + delete thinking.budgetTokens; + } else { + const normalizedBudget = Math.max(1024, Math.floor(budgetTokens)); + thinking.budget_tokens = normalizedBudget; + delete thinking.budgetTokens; + } + + extraArgs.thinking = thinking; + commitExtraArgs(target.entry, extraArgsKey, extraArgs); + return nextSettings; +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 5bef6e73..b3ded797 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -16,6 +16,7 @@ import { import { useDroid } from '@/hooks/use-droid'; import { isApiConflictError } from '@/lib/api-client'; import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { DroidByokReasoningControlsCard } from '@/components/compatible-cli/droid-byok-reasoning-controls-card'; import { DroidSettingsQuickControlsCard, type DroidQuickSettingsValues, @@ -25,6 +26,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +import { + applyAnthropicBudgetTokensToDroidByokModel, + applyReasoningEffortToDroidByokModel, + extractDroidByokModels, +} from '@/lib/droid-byok-custom-models'; const DEFAULT_DROID_FACTORY_DOC_LINKS = [ { @@ -188,6 +194,10 @@ export function DroidPage() { setRawDraftText(nextText); }; + const updateSettingsObject = (nextSettings: Record) => { + setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + }; + const updateSettingsField = (key: string, value: unknown | null) => { if (!rawEditorParsed.valid) { toast.error('Fix JSON syntax before using quick settings controls.'); @@ -200,7 +210,7 @@ export function DroidPage() { } else { nextSettings[key] = value; } - setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + updateSettingsObject(nextSettings); }; const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid @@ -229,6 +239,8 @@ export function DroidPage() { soundEnabled: null, }; + const byokModels = rawEditorParsed.valid ? extractDroidByokModels(rawEditorParsed.value) : []; + const refreshAll = async () => { await Promise.all([refetchDiagnostics(), refetchRawSettings()]); }; @@ -383,6 +395,52 @@ export function DroidPage() { }} /> + { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); + return; + } + + const nextSettings = applyReasoningEffortToDroidByokModel( + rawEditorParsed.value, + modelId, + effort + ); + if (!nextSettings) { + toast.error('Unable to update selected BYOK model reasoning setting.'); + return; + } + + updateSettingsObject(nextSettings); + }} + onAnthropicBudgetChange={(modelId, budgetTokens) => { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating thinking budget.'); + return; + } + + const nextSettings = applyAnthropicBudgetTokensToDroidByokModel( + rawEditorParsed.value, + modelId, + budgetTokens + ); + if (!nextSettings) { + toast.error('Thinking budget is only available for Anthropic BYOK models.'); + return; + } + + updateSettingsObject(nextSettings); + }} + /> + diff --git a/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts b/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts new file mode 100644 index 00000000..7dea96d5 --- /dev/null +++ b/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + applyAnthropicBudgetTokensToDroidByokModel, + applyReasoningEffortToDroidByokModel, + extractDroidByokModels, +} from '@/lib/droid-byok-custom-models'; + +describe('extractDroidByokModels', () => { + it('extracts modern and legacy custom model key styles', () => { + const settings = { + customModels: [ + { + displayName: 'GPT-5.2 High', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: { + reasoning: { effort: 'high' }, + }, + }, + ], + custom_models: [ + { + model_display_name: 'GLM Legacy', + model: 'glm-4.7', + provider: 'generic-chat-completion-api', + extraArgs: { + reasoning_effort: 'medium', + }, + }, + ], + }; + + const models = extractDroidByokModels(settings); + + expect(models).toHaveLength(2); + expect(models[0].displayName).toBe('GPT-5.2 High'); + expect(models[0].effort).toBe('high'); + expect(models[1].displayName).toBe('GLM Legacy'); + expect(models[1].effort).toBe('medium'); + }); + + it('infers anthropic effort from thinking budget tokens', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Thinking', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: { + thinking: { + type: 'enabled', + budget_tokens: 30000, + }, + }, + }, + ], + }; + + const models = extractDroidByokModels(settings); + + expect(models).toHaveLength(1); + expect(models[0].providerKind).toBe('anthropic'); + expect(models[0].effort).toBe('high'); + expect(models[0].anthropicBudgetTokens).toBe(30000); + }); +}); + +describe('applyReasoningEffortToDroidByokModel', () => { + it('updates generic provider to reasoning_effort', () => { + const settings = { + customModels: [ + { + displayName: 'GLM Profile', + model: 'glm-4.7', + provider: 'generic-chat-completion-api', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + expect((updated.extraArgs as Record).reasoning_effort).toBe('high'); + }); + + it('updates openai provider to reasoning.effort', () => { + const settings = { + customModels: [ + { + displayName: 'GPT Profile', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const extraArgs = updated.extraArgs as Record; + expect((extraArgs.reasoning as Record).effort).toBe('high'); + expect(extraArgs.reasoning_effort).toBeUndefined(); + }); + + it('updates anthropic provider to thinking config with budget', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Profile', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const thinking = ((updated.extraArgs as Record).thinking ?? {}) as Record< + string, + unknown + >; + expect(thinking.type).toBe('enabled'); + expect(thinking.budget_tokens).toBe(30000); + }); +}); + +describe('applyAnthropicBudgetTokensToDroidByokModel', () => { + it('sets anthropic thinking budget tokens', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Profile', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: { + thinking: { type: 'enabled', budget_tokens: 30000 }, + }, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyAnthropicBudgetTokensToDroidByokModel(settings, modelId, 40960); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const thinking = ((updated.extraArgs as Record).thinking ?? {}) as Record< + string, + unknown + >; + expect(thinking.type).toBe('enabled'); + expect(thinking.budget_tokens).toBe(40960); + }); + + it('returns null for non-anthropic providers', () => { + const settings = { + customModels: [ + { + displayName: 'GPT Profile', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyAnthropicBudgetTokensToDroidByokModel(settings, modelId, 40960); + + expect(next).toBeNull(); + }); +}); From a88fd68c34ea94555ab9efe89ead8dbe9d72a8a5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 12:57:13 +0700 Subject: [PATCH 59/94] fix(droid): restore tabbed layout and stabilize save state --- ui/src/hooks/use-droid.ts | 42 ++- ui/src/pages/droid.tsx | 665 ++++++++++++++++++++------------------ 2 files changed, 397 insertions(+), 310 deletions(-) diff --git a/ui/src/hooks/use-droid.ts b/ui/src/hooks/use-droid.ts index 7339829e..c35a3232 100644 --- a/ui/src/hooks/use-droid.ts +++ b/ui/src/hooks/use-droid.ts @@ -97,6 +97,30 @@ interface SaveDroidRawSettingsResponse { mtime: number; } +function parseDroidRawSettingsText(rawText: string): { + settings: Record | null; + parseError: string | null; +} { + try { + const parsed = JSON.parse(rawText); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + settings: null, + parseError: 'JSON root must be an object.', + }; + } + return { + settings: parsed as Record, + parseError: null, + }; + } catch (error) { + return { + settings: null, + parseError: (error as Error).message, + }; + } +} + async function fetchDroidDiagnostics(): Promise { const res = await fetch(withApiBase('/droid/diagnostics')); if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); @@ -142,9 +166,23 @@ export function useDroid() { const saveRawSettingsMutation = useMutation({ mutationFn: saveDroidRawSettings, - onSuccess: () => { + onSuccess: (result, variables) => { + queryClient.setQueryData(['droid-raw-settings'], (current) => { + const path = current?.path ?? '~/.factory/settings.json'; + const resolvedPath = current?.resolvedPath ?? path; + const parsed = parseDroidRawSettingsText(variables.rawText); + + return { + path, + resolvedPath, + exists: true, + mtime: result.mtime, + rawText: variables.rawText, + settings: parsed.settings, + parseError: parsed.parseError, + }; + }); queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] }); - queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] }); }, }); diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index b3ded797..0a1dea5d 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -25,6 +25,7 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; import { applyAnthropicBudgetTokensToDroidByokModel, @@ -257,7 +258,6 @@ export function DroidPage() { expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined, }); setRawDraftText(null); - await Promise.all([refetchDiagnostics(), refetchRawSettings()]); toast.success('Droid settings saved'); } catch (error) { if (isApiConflictError(error)) { @@ -308,321 +308,370 @@ export function DroidPage() { const providerValues = docsReference.providerValues ?? []; const settingsHierarchy = docsReference.settingsHierarchy ?? []; + const tabContentClassName = 'mt-0 h-full border-0 p-0 data-[state=inactive]:hidden'; + return ( - -
- - - - - Runtime & Installation - - - -
- Status - - {diagnostics.binary.installed ? 'Detected' : 'Not Found'} - -
- - - - - -
-
+ +
+ + Overview + BYOK + Docs + +
- - - - - Config Files - - - - {[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => ( -
-
- {file.label} - {file.exists ? ( - - ) : ( - - )} -
- - - - - {file.parseError && ( -

Parse warning: {file.parseError}

- )} - {file.readError && ( -

Read warning: {file.readError}

- )} -
- ))} -
-
+
+ + +
+ + + + + Runtime & Installation + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + +
+ + + + + +
+
- { - updateSettingsField(key, value); - }} - onBooleanSettingChange={(key, value) => { - updateSettingsField(key, value); - }} - onNumberSettingChange={(key, value) => { - updateSettingsField(key, value); - }} - /> - - { - if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); - return; - } - - const nextSettings = applyReasoningEffortToDroidByokModel( - rawEditorParsed.value, - modelId, - effort - ); - if (!nextSettings) { - toast.error('Unable to update selected BYOK model reasoning setting.'); - return; - } - - updateSettingsObject(nextSettings); - }} - onAnthropicBudgetChange={(modelId, budgetTokens) => { - if (!rawEditorParsed.valid) { - toast.error('Fix JSON syntax before updating thinking budget.'); - return; - } - - const nextSettings = applyAnthropicBudgetTokensToDroidByokModel( - rawEditorParsed.value, - modelId, - budgetTokens - ); - if (!nextSettings) { - toast.error('Thinking budget is only available for Anthropic BYOK models.'); - return; - } - - updateSettingsObject(nextSettings); - }} - /> - - - - - - BYOK Summary - - - - - - - - - -
-

Providers

-
- {providerRows.length === 0 && ( - - none - - )} - {providerRows.map(([provider, count]) => ( - - {provider}: {count} - - ))} -
-
-
-
- - - - - - Docs-Aligned Notes - - - - {docsNotes.map((note, index) => ( -

- - {renderTextWithLinks(note)} -

- ))} - -
-

- Factory Docs -

- -
- - - -

- Provider values: {providerValues.join(', ')} -

-

- Settings hierarchy: {settingsHierarchy.join(' -> ')} -

-
-
- - - - Custom Models - - -
-
- Name / Model - Provider - Base URL -
- -
- {customModels.length === 0 && ( -
- No custom models -
- )} - {customModels.map((model) => ( -
-
-

{model.displayName}

-

{model.model}

-
-
-

{model.provider}

-

{model.apiKeyPreview || 'no-key'}

-
-
-

- {model.host || model.baseUrl} -

-

- {model.baseUrl} -

+ + + + + Config Files + + + + {[diagnostics.files.settings, diagnostics.files.legacyConfig].map((file) => ( +
+
+ {file.label} + {file.exists ? ( + + ) : ( + + )}
+ + + + + {file.parseError && ( +

Parse warning: {file.parseError}

+ )} + {file.readError && ( +

Read warning: {file.readError}

+ )}
))} -
- -
- - + + - {diagnostics.warnings.length > 0 && ( - - - - - Warnings - - - - {diagnostics.warnings.map((warning) => ( -

- - {warning} -

- ))} -
-
- )} + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ + + + +
+ { + updateSettingsField(key, value); + }} + onBooleanSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + onNumberSettingChange={(key, value) => { + updateSettingsField(key, value); + }} + /> + + { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); + return; + } + + const nextSettings = applyReasoningEffortToDroidByokModel( + rawEditorParsed.value, + modelId, + effort + ); + if (!nextSettings) { + toast.error('Unable to update selected BYOK model reasoning setting.'); + return; + } + + updateSettingsObject(nextSettings); + }} + onAnthropicBudgetChange={(modelId, budgetTokens) => { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating thinking budget.'); + return; + } + + const nextSettings = applyAnthropicBudgetTokensToDroidByokModel( + rawEditorParsed.value, + modelId, + budgetTokens + ); + if (!nextSettings) { + toast.error('Thinking budget is only available for Anthropic BYOK models.'); + return; + } + + updateSettingsObject(nextSettings); + }} + /> + + + + + + BYOK Summary + + + + + + + + + +
+

Providers

+
+ {providerRows.length === 0 && ( + + none + + )} + {providerRows.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+
+
+ + + + Custom Models + + +
+
+ Name / Model + Provider + Base URL +
+ +
+ {customModels.length === 0 && ( +
+ No custom models +
+ )} + {customModels.map((model) => ( +
+
+

{model.displayName}

+

+ {model.model} +

+
+
+

{model.provider}

+

+ {model.apiKeyPreview || 'no-key'} +

+
+
+

+ {model.host || model.baseUrl} +

+

+ {model.baseUrl} +

+
+
+ ))} +
+
+
+
+
+
+
+
+ + + +
+ + + + + Docs-Aligned Notes + + + + {docsNotes.map((note, index) => ( +

+ - {renderTextWithLinks(note)} +

+ ))} + +
+

+ Factory Docs +

+ +
+ + + +

+ Provider values: {providerValues.join(', ')} +

+

+ Settings hierarchy: {settingsHierarchy.join(' -> ')} +

+
+
+
+
+
- + ); }; From dec3905d4de5c5839025161d3c4b8e166620e64a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 06:42:39 +0000 Subject: [PATCH 60/94] chore(release): 7.50.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f20b3e2..bde99897 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.3", + "version": "7.50.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 48f5d37c1067e3ea895daef422457795d318cc2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 06:45:58 +0000 Subject: [PATCH 61/94] chore(release): 7.50.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bde99897..6fbc7b00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.4", + "version": "7.50.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 03bc4140a1c668fa96476319fb8f780661620417 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:04:32 +0700 Subject: [PATCH 62/94] fix(iflow): replace placeholder model defaults to prevent 406 --- config/base-iflow.settings.json | 8 +-- src/cliproxy/config/env-builder.ts | 63 +++++++++++++++++++ .../cliproxy/env-builder-provider-url.test.ts | 45 +++++++++++++ ui/src/lib/model-catalogs.ts | 54 ++++++++++++++-- 4 files changed, 162 insertions(+), 8 deletions(-) diff --git a/config/base-iflow.settings.json b/config/base-iflow.settings.json index 2fc56b47..4d9c3ba2 100644 --- a/config/base-iflow.settings.json +++ b/config/base-iflow.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/iflow", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "deepseek-v3.2", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2-thinking", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v3.2", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2" + "ANTHROPIC_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder-plus" } } diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 5347962d..f90a0691 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -37,6 +37,8 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const IFLOW_PLACEHOLDER_MODEL = 'iflow-default'; +const IFLOW_DEFAULT_MODEL = 'qwen3-coder-plus'; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -136,6 +138,61 @@ function migrateCodexEffortSuffixes( return migrated; } +/** + * Migrate legacy iFlow placeholder model IDs to a real default model. + * Example: iflow-default -> qwen3-coder-plus + */ +function migrateIFlowPlaceholderModel( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { + if (provider !== 'iflow') return false; + if (!settings.env || typeof settings.env !== 'object') return false; + + let migrated = false; + const normalize = (value: string): string => value.trim().toLowerCase(); + const replaceIfPlaceholder = (value: string): string => + normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value; + + for (const key of MODEL_ENV_VAR_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const replaced = replaceIfPlaceholder(value); + if (replaced !== value) { + settings.env[key] = replaced; + migrated = true; + } + } + + if (Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + const replaced = replaceIfPlaceholder(value); + if (replaced !== value) { + presetRecord[key] = replaced; + migrated = true; + } + } + } + } + + if (migrated) { + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); + } catch { + // Best-effort migration — don't block startup if write fails + } + } + + return migrated; +} + /** Remote proxy configuration for URL rewriting */ export interface RemoteProxyRewriteConfig { host: string; @@ -355,6 +412,8 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(expandedPath, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(expandedPath, provider, settings); + // Migrate legacy iFlow placeholders to supported model IDs + migrateIFlowPlaceholderModel(expandedPath, provider, settings); // Custom variant settings found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -388,6 +447,8 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(settingsPath, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(settingsPath, provider, settings); + // Migrate legacy iFlow placeholders to supported model IDs + migrateIFlowPlaceholderModel(settingsPath, provider, settings); // User override found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -525,6 +586,7 @@ export function getRemoteEnvVars( if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, settings); migrateCodexEffortSuffixes(expandedPath, provider, settings); + migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; } } catch { @@ -544,6 +606,7 @@ export function getRemoteEnvVars( if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, settings); migrateCodexEffortSuffixes(settingsPath, provider, settings); + migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; } } catch { diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 63efe2c7..9c6f9002 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -146,6 +146,51 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); }); + it('migrates iflow placeholder model IDs to a supported default', () => { + const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json'); + writeSettings( + iflowSettingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'iflow-default', + }, + { + presets: [ + { + name: 'legacy-iflow', + default: 'iflow-default', + opus: 'iflow-default', + sonnet: 'iflow-default', + haiku: 'iflow-default', + }, + ], + } + ); + + const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath); + expect(env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); + + const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.default).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.sonnet).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); + }); + it('repairs existing provider settings files that are missing env keys', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 3e398d05..0a8e98f3 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -288,12 +288,58 @@ export const MODEL_CATALOGS: Record = { iflow: { provider: 'iflow', displayName: 'iFlow', - defaultModel: 'iflow-default', + defaultModel: 'qwen3-coder-plus', models: [ { - id: 'iflow-default', - name: 'iFlow Default', - description: 'Default iFlow model', + id: 'qwen3-coder-plus', + name: 'Qwen3 Coder Plus', + description: 'Recommended default for iFlow accounts', + presetMapping: { + default: 'qwen3-coder-plus', + opus: 'qwen3-coder-plus', + sonnet: 'qwen3-coder-plus', + haiku: 'qwen3-coder-plus', + }, + }, + { + id: 'qwen3-max', + name: 'Qwen3 Max', + description: 'Flagship Qwen model via iFlow', + }, + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + description: 'Latest Kimi model via iFlow', + }, + { + id: 'kimi-k2', + name: 'Kimi K2', + description: 'Kimi general model', + }, + { + id: 'deepseek-v3.2-chat', + name: 'DeepSeek V3.2 Chat', + description: 'Stable DeepSeek chat model', + }, + { + id: 'deepseek-r1', + name: 'DeepSeek R1', + description: 'Reasoning-focused DeepSeek model', + }, + { + id: 'glm-4.7', + name: 'GLM 4.7', + description: 'Zhipu GLM 4.7 via iFlow', + }, + { + id: 'minimax-m2.5', + name: 'MiniMax M2.5', + description: 'MiniMax M2.5 via iFlow', + }, + { + id: 'qwen3-vl-plus', + name: 'Qwen3 VL Plus', + description: 'Vision-language model', }, ], }, From cfa611fe480be0461954ee8cb1ad5317e46dc72c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:12:41 +0700 Subject: [PATCH 63/94] chore(maintainability): refresh baseline metrics --- docs/metrics/maintainability-baseline.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 734ab29f..b97912b0 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -1,9 +1,9 @@ { "sourceDirectory": "src", "largeFileThresholdLoc": 350, - "typeScriptFileCount": 355, - "locInSrc": 71353, + "typeScriptFileCount": 385, + "locInSrc": 80615, "processExitReferenceCount": 185, - "synchronousFsApiReferenceCount": 879, + "synchronousFsApiReferenceCount": 884, "largeFileCountOver350Loc": 56 } From 7ac5e3edab0e06d817933b3597038561e4995392 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:15:32 +0700 Subject: [PATCH 64/94] fix(pricing): normalize model aliases for MiniMax and Qwen --- src/web-server/model-pricing.ts | 90 +++++++++++++++++++++++--------- tests/unit/model-pricing.test.ts | 39 ++++++++++++++ 2 files changed, 105 insertions(+), 24 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 59054efc..4513d36c 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -686,6 +686,14 @@ const PRICING_REGISTRY: Record = { }, }; +const MODEL_PRICING_ALIASES: Record = { + // Keep catalog-only IDs on explicit priced equivalents. + 'qwen3-coder': 'qwen3-coder-plus', + 'qwen3-235b': 'qwen3-max', + 'qwen3-vl-plus': 'qwen3.5-plus', + 'qwen3-32b': 'qwen3.5-plus', +}; + // Default pricing for unknown models const UNKNOWN_MODEL_PRICING: ModelPricing = { inputPerMillion: 3.0, @@ -704,39 +712,76 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { */ function normalizeModelName(model: string): string { // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") - const normalized = model.toLowerCase().replace(/^[^/]+\//, ''); + const normalized = model + .trim() + .toLowerCase() + .replace(/^[^/]+\//, ''); return normalized; } +const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( + PRICING_REGISTRY +).reduce>((acc, [key, pricing]) => { + acc[normalizeModelName(key)] = pricing; + return acc; +}, {}); + +function getLookupCandidates(model: string): string[] { + const normalized = normalizeModelName(model); + const baseModel = normalized.split(':')[0]; + + return baseModel === normalized ? [normalized] : [normalized, baseModel]; +} + +function getDirectOrAliasPricing(model: string): ModelPricing | undefined { + const directPricing = PRICING_REGISTRY[model]; + if (directPricing !== undefined) { + return directPricing; + } + + for (const candidate of getLookupCandidates(model)) { + const normalizedPricing = NORMALIZED_PRICING_REGISTRY[candidate]; + if (normalizedPricing !== undefined) { + return normalizedPricing; + } + + const alias = MODEL_PRICING_ALIASES[candidate]; + if (alias !== undefined) { + const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias]; + if (aliasPricing !== undefined) { + return aliasPricing; + } + } + } + + return undefined; +} + /** * Get pricing for a model with fuzzy matching fallback * @param model - Model name (exact or with provider prefix) * @returns ModelPricing for the model or fallback pricing */ export function getModelPricing(model: string): ModelPricing { - // Try exact match first - if (PRICING_REGISTRY[model]) { - return PRICING_REGISTRY[model]; + const directOrAliasPricing = getDirectOrAliasPricing(model); + if (directOrAliasPricing !== undefined) { + return directOrAliasPricing; } - // Try normalized match - const normalized = normalizeModelName(model); - if (PRICING_REGISTRY[normalized]) { - return PRICING_REGISTRY[normalized]; - } - - // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - if (normalized.endsWith(key) || key.endsWith(normalized)) { - return pricing; + for (const candidate of getLookupCandidates(model)) { + // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + if (candidate.endsWith(key) || key.endsWith(candidate)) { + return pricing; + } } - } - // Try partial matching for model families - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - // Match by model family prefix - if (normalized.startsWith(key.split('-').slice(0, 2).join('-'))) { - return pricing; + // Try partial matching for model families + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + // Match by model family prefix + if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) { + return pricing; + } } } @@ -773,8 +818,5 @@ export function getKnownModels(): string[] { * Check if a model has custom pricing (not using fallback) */ export function hasCustomPricing(model: string): boolean { - return ( - PRICING_REGISTRY[model] !== undefined || - PRICING_REGISTRY[normalizeModelName(model)] !== undefined - ); + return getDirectOrAliasPricing(model) !== undefined; } diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 3b182b8b..30efdc59 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -41,6 +41,41 @@ describe('model-pricing', () => { // Should match via normalization }); + it('should resolve lowercase MiniMax model IDs to custom pricing', () => { + const pricing = getModelPricing('minimax-m2.5'); + expect(pricing.inputPerMillion).toBe(0.3); + expect(pricing.outputPerMillion).toBe(1.2); + }); + + it('should resolve provider-prefixed MiniMax model IDs to custom pricing', () => { + const pricing = getModelPricing('minimax/MiniMax-M2.5'); + expect(pricing.inputPerMillion).toBe(0.3); + expect(pricing.outputPerMillion).toBe(1.2); + }); + + it('should use updated MiniMax-M2.1-lightning input pricing', () => { + const pricing = getModelPricing('MiniMax-M2.1-lightning'); + expect(pricing.inputPerMillion).toBe(0.6); + }); + + it('should not use fallback pricing for known Qwen catalog IDs', () => { + const fallback = getModelPricing('unknown-model-xyz'); + const catalogIds = ['qwen3-235b', 'qwen3-vl-plus', 'qwen3-32b']; + + for (const model of catalogIds) { + const pricing = getModelPricing(model); + expect(pricing).not.toEqual(fallback); + } + }); + + it('should map qwen3-coder to deterministic custom pricing', () => { + const pricing = getModelPricing('qwen3-coder'); + const canonical = getModelPricing('qwen3-coder-plus'); + + expect(pricing).toEqual(canonical); + expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz')); + }); + it('should return different pricing for different model tiers', () => { const sonnet = getModelPricing('claude-sonnet-4-5'); const opus = getModelPricing('claude-opus-4-5-20251101'); @@ -134,6 +169,10 @@ describe('model-pricing', () => { expect(hasCustomPricing('glm-4.6')).toBe(true); }); + it('should return true for deterministic qwen3-coder alias', () => { + expect(hasCustomPricing('qwen3-coder')).toBe(true); + }); + it('should return false for unknown models', () => { expect(hasCustomPricing('unknown-model-xyz')).toBe(false); }); From 6d752724ece669dd1b25f28df9dcf1554d1569e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 07:32:03 +0000 Subject: [PATCH 65/94] chore(release): 7.50.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6fbc7b00..77951c30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.5", + "version": "7.50.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a8b8e633efd06399f120baf2128b1c2921ac4e99 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:32:38 +0700 Subject: [PATCH 66/94] fix(ci): align maintainability baseline for PR merge checks --- docs/metrics/maintainability-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index 734ab29f..ada3a1a1 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -4,6 +4,6 @@ "typeScriptFileCount": 355, "locInSrc": 71353, "processExitReferenceCount": 185, - "synchronousFsApiReferenceCount": 879, + "synchronousFsApiReferenceCount": 884, "largeFileCountOver350Loc": 56 } From ff9dbb30614263b1f0b0751f1277728b636ef236 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:06:04 +0700 Subject: [PATCH 67/94] fix(cliproxy): normalize deprecated antigravity Claude aliases --- src/cliproxy/config/generator.ts | 29 +++++++++++----- tests/unit/cliproxy/config-generator.test.js | 36 +++++++++++++++++++- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 27e649d4..0b88f6f0 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -31,8 +31,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v7: Added fork:true for Claude model aliases (keep both upstream and alias names) * v8: Added Gemini 3.1 preview aliases for provider routing compatibility * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment + * v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases */ -export const CLIPROXY_CONFIG_VERSION = 9; +export const CLIPROXY_CONFIG_VERSION = 10; interface OAuthModelAliasEntry { name: string; @@ -41,6 +42,8 @@ interface OAuthModelAliasEntry { } const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; +const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-'; +const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-'; /** * Default Antigravity oauth-model-alias entries. @@ -54,12 +57,12 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, - { name: 'claude-sonnet-4-6', alias: 'gemini-claude-sonnet-4-6', fork: true }, - { name: 'claude-sonnet-4-6-thinking', alias: 'gemini-claude-sonnet-4-6-thinking', fork: true }, - { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true }, - { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true }, - { name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true }, - { name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true }, + { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, + { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true }, + { name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true }, + { name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true }, + { name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true }, + { name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true }, ]; /** @@ -101,6 +104,16 @@ function sanitizeYamlScalar(rawValue: string): string { return trimmed; } +function normalizeAntigravityAlias(rawAlias: string): string { + const normalized = sanitizeYamlScalar(rawAlias); + if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) { + return ( + UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length) + ); + } + return normalized; +} + function addAliasEntry( entries: OAuthModelAliasEntry[], indexByKey: Map, @@ -108,7 +121,7 @@ function addAliasEntry( ): void { const normalized: OAuthModelAliasEntry = { name: sanitizeYamlScalar(entry.name), - alias: sanitizeYamlScalar(entry.alias), + alias: normalizeAntigravityAlias(entry.alias), fork: entry.fork || undefined, }; if (!normalized.name || !normalized.alias) return; diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 772d7a9b..d32269f1 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -587,11 +587,15 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model'); assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model'); assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases'); + assert( + !config.includes('alias: gemini-claude-'), + 'Should not emit deprecated gemini-claude aliases' + ); // Verify fork: true appears after each Claude alias entry const lines = config.split('\n'); for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('alias: gemini-claude-')) { + if (lines[i].includes('alias: claude-')) { assert( lines[i + 1] && lines[i + 1].trim() === 'fork: true', `fork: true should follow Claude alias at line ${i}: ${lines[i]}` @@ -766,5 +770,35 @@ oauth-model-alias: } } }); + + it('normalizes deprecated gemini-claude aliases during regeneration', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v9 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: claude-sonnet-4-6-thinking + alias: gemini-claude-sonnet-4-6-thinking + fork: true +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: claude-sonnet-4-6-thinking'), + 'Should include normalized upstream Claude alias' + ); + assert( + !newConfig.includes('alias: gemini-claude-sonnet-4-6-thinking'), + 'Should remove deprecated gemini-claude alias after regeneration' + ); + }); }); }); From fdb32e2c535e541e1d7336099aed94a180711bcb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:44:00 +0700 Subject: [PATCH 68/94] fix(maintainability): make gate parallel-pr friendly --- .github/workflows/ci.yml | 2 +- .github/workflows/dev-release.yml | 2 +- .github/workflows/release.yml | 2 +- CLAUDE.md | 30 +++++- docs/project-roadmap.md | 6 ++ package.json | 4 +- scripts/maintainability-check.js | 163 ++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 scripts/maintainability-check.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76254243..e4041af2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,5 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [warn on PR] + tests) run: bun run validate diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index c32b9f91..35db2a30 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -42,7 +42,7 @@ jobs: - name: Build run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04485546..1d91bfe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + tests) + - name: Validate (typecheck + lint + format + maintainability [strict] + tests) run: bun run validate - name: Release diff --git a/CLAUDE.md b/CLAUDE.md index 80f46d55..8c5be4d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ CLI wrapper for instant switching between multiple provider accounts and alterna | Mistake | Consequence | Correct Action | |---------|-------------|----------------| | Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate | +| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code | | Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix | | Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs | | Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning | @@ -59,7 +60,7 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow. # Main project (from repo root) bun run format # Step 1: Fix formatting bun run lint:fix # Step 2: Fix lint issues -bun run validate # Step 3: Full test gate (must pass) +bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests) bun run validate:ci-parity # Step 4: CI parity gate (build + validate + base branch check) # UI project (if UI changed) @@ -78,7 +79,7 @@ bun run validate # Step 3: Final check (must pass) | Project | Command | Runs | |---------|---------|------| -| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all | +| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all | | UI | `bun run validate` | typecheck + lint:fix + format:check | ### ESLint Rules (ALL errors) @@ -105,10 +106,31 @@ bun run validate # Step 3: Final check (must pass) ### Automatic Enforcement - `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js` -- CI/CD runs `bun run validate` on every PR +- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events) - husky `pre-commit` runs quick lint/type/format checks - husky `pre-push` runs `bun run validate:ci-parity` to block CI drift before push +### Maintainability Baseline Gate + +- Baseline file: `docs/metrics/maintainability-baseline.json` +- Metric collector/check script: `scripts/maintainability-baseline.js` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` +- Enforcement path: `bun run maintainability:check` (included in `bun run validate`) +- Gate modes: + - `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs + - `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow) + - override commands: + - `bun run maintainability:check:strict` + - `bun run maintainability:check:warn` +- Gated metrics (must not increase vs baseline): + - `processExitReferenceCount` + - `synchronousFsApiReferenceCount` + - `largeFileCountOver350Loc` +- Baseline update policy: + 1. Prefer reducing the metric and keeping the baseline unchanged. + 2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`. + 3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description. + ## Critical Constraints (NEVER VIOLATE) 1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i] @@ -359,6 +381,8 @@ rm -rf ~/.ccs # Clean environment - [ ] `bun run validate` — all checks pass - [ ] `bun run validate:ci-parity` — CI parity passed (also enforced by pre-push hook) - [ ] `cd ui && bun run format && bun run validate` — if UI changed +- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR +- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json` **Code:** - [ ] Conventional commit format (`feat:`, `fix:`, etc.) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 07598a41..2ef26085 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -203,15 +203,21 @@ All criteria achieved: ## Maintainability Gate (Issue #539 Foundation) - Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Branch-aware gate wrapper: `scripts/maintainability-check.js` - Generate or refresh baseline: - `bun run maintainability:baseline` - `npm run maintainability:baseline` - Run regression check gate: - `bun run maintainability:check` - `npm run maintainability:check` + - `bun run maintainability:check:strict` (force strict locally) The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. +Default gate behavior: +- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) +- warning mode on PR CI and non-protected branches (parallel PR friendly) + The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references - synchronous fs API references diff --git a/package.json b/package.json index 77951c30..c14f6700 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,9 @@ "validate:ci-parity": "bash scripts/ci-parity-gate.sh", "verify:bundle": "node scripts/verify-bundle.js", "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", - "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-check.js", + "maintainability:check:strict": "node scripts/maintainability-check.js --strict", + "maintainability:check:warn": "node scripts/maintainability-check.js --warn", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", diff --git a/scripts/maintainability-check.js b/scripts/maintainability-check.js new file mode 100644 index 00000000..8971ef84 --- /dev/null +++ b/scripts/maintainability-check.js @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +const { execFileSync, spawnSync } = require('child_process'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const BASELINE_FILE = path.join('docs', 'metrics', 'maintainability-baseline.json'); +const BASELINE_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'maintainability-baseline.js'); + +const PROTECTED_BRANCHES = new Set(['main', 'dev']); +const HOTFIX_PREFIXES = ['hotfix/', 'kai/hotfix-']; + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function detectBranchName() { + try { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function isProtectedBranch(branchName) { + if (!branchName) { + return false; + } + + if (PROTECTED_BRANCHES.has(branchName)) { + return true; + } + + return HOTFIX_PREFIXES.some(prefix => branchName.startsWith(prefix)); +} + +function detectMode() { + if (hasFlag('--strict')) { + return 'strict'; + } + + if (hasFlag('--warn')) { + return 'warn'; + } + + if (hasFlag('--off')) { + return 'off'; + } + + const explicitMode = (process.env.CCS_MAINTAINABILITY_MODE || '').toLowerCase().trim(); + if (explicitMode === 'strict' || explicitMode === 'warn' || explicitMode === 'off') { + return explicitMode; + } + + const eventName = process.env.GITHUB_EVENT_NAME || ''; + if (eventName === 'pull_request' || eventName === 'pull_request_target') { + return 'warn'; + } + + const gitHubRef = process.env.GITHUB_REF || ''; + if (gitHubRef.startsWith('refs/heads/')) { + const branchFromRef = gitHubRef.slice('refs/heads/'.length); + if (isProtectedBranch(branchFromRef)) { + return 'strict'; + } + } + + return isProtectedBranch(detectBranchName()) ? 'strict' : 'warn'; +} + +function runBaselineCheck() { + return spawnSync('node', [BASELINE_SCRIPT, '--check', BASELINE_FILE], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function writeStreams(result) { + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } +} + +function tryParseJson(stdout) { + if (!stdout) { + return null; + } + + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + +function formatViolations(violations) { + if (!Array.isArray(violations) || violations.length === 0) { + return []; + } + + return violations.map(violation => { + if (!violation || typeof violation !== 'object') { + return '- unknown violation'; + } + + const metric = violation.metric || 'unknown'; + const baseline = typeof violation.baseline === 'number' ? violation.baseline : 'n/a'; + const current = typeof violation.current === 'number' ? violation.current : 'n/a'; + return `- ${metric}: baseline=${baseline}, current=${current}`; + }); +} + +function main() { + const mode = detectMode(); + + if (mode === 'off') { + console.log('[i] Maintainability gate disabled (mode=off).'); + process.exit(0); + } + + const result = runBaselineCheck(); + if (mode === 'strict') { + writeStreams(result); + process.exit(result.status === null ? 1 : result.status); + } + + if (result.status === 0) { + writeStreams(result); + process.exit(0); + } + + const parsed = tryParseJson(result.stdout); + const branchName = detectBranchName(); + + console.log('[!] Maintainability regression detected (warning-only mode).'); + if (branchName) { + console.log(`[i] Branch: ${branchName}`); + } + + if (parsed && Array.isArray(parsed.violations) && parsed.violations.length > 0) { + console.log('[i] Violations:'); + for (const line of formatViolations(parsed.violations)) { + console.log(line); + } + } else { + writeStreams(result); + } + + console.log('[i] This is non-blocking on PR/feature branches to support parallel workflow.'); + console.log( + '[i] Use strict mode when needed: bun run maintainability:check:strict' + ); +} + +main(); From e4ed0fd4814f40a8ce480d27eab73950a8662607 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 07:49:26 +0000 Subject: [PATCH 69/94] chore(release): 7.50.0-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c14f6700..8e5f67ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.50.0-dev.6", + "version": "7.50.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 346fa5fcda7f740b01a219575f466452adbfdfb2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 14:11:43 +0700 Subject: [PATCH 70/94] fix(shared): support file-based agents and recursive commands --- src/web-server/shared-routes.ts | 294 ++++++++++++++++---- tests/unit/web-server/shared-routes.test.ts | 80 +++++- ui/src/pages/shared.tsx | 38 ++- 3 files changed, 351 insertions(+), 61 deletions(-) diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 1682cab0..4dcf1a5b 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -7,6 +7,7 @@ import { Router, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; +import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { getClaudeConfigDir } from '../utils/claude-config-path'; @@ -70,7 +71,7 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { const items: SharedItem[] = []; const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); - const allowedSkillAgentRoots = new Set([ + const allowedRoots = new Set([ sharedDirRoot, ...[ path.join(getClaudeConfigDir(), type), @@ -81,62 +82,35 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { .filter((dirPath): dirPath is string => typeof dirPath === 'string'), ]); + if (type === 'commands') { + return getCommandItems(sharedDir, allowedRoots); + } + try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); for (const entry of entries) { try { const entryPath = path.join(sharedDir, entry.name); - - if (type === 'commands') { - if (!entry.name.endsWith('.md')) { - continue; - } - if (!entry.isFile() && !entry.isSymbolicLink()) { - continue; - } - - const commandPath = safeRealPath(entryPath); - if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) { - continue; - } - - const description = readMarkdownDescription(commandPath, sharedDirRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name.replace('.md', ''), - description, - path: entryPath, - type: 'command', - }); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { continue; } - // Skills/agents are directory-based and may be symlinked directories. - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + const stats = fs.statSync(resolvedEntryPath); + const item = getSkillOrAgentItem( + type, + entry, + entryPath, + resolvedEntryPath, + allowedRoots, + stats + ); + if (!item) { continue; } - const entryRoot = safeRealPath(entryPath); - if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) { - continue; - } - - const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md'; - const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot); - if (!description) { - continue; - } - - items.push({ - name: entry.name, - description, - path: entryPath, - type: type === 'skills' ? 'skill' : 'agent', - }); + items.push(item); } catch { // Fail soft per entry so one bad item does not hide valid results. } @@ -148,22 +122,234 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] { return items.sort((a, b) => a.name.localeCompare(b.name)); } -function extractDescription(content: string): string { - // Extract first non-empty, non-heading line - const lines = content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) { - return trimmed.slice(0, 100); +function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); + const items: SharedItem[] = []; + + for (const markdownFile of markdownFiles) { + const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots); + if (!description) { + continue; + } + + const relativePath = path.relative(sharedDir, markdownFile.displayPath); + const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, ''); + if (!normalizedName) { + continue; + } + + items.push({ + name: normalizedName, + description, + path: markdownFile.displayPath, + type: 'command', + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); +} + +function getSkillOrAgentItem( + type: 'skills' | 'agents', + entry: fs.Dirent, + entryPath: string, + resolvedEntryPath: string, + allowedRoots: Set, + stats: fs.Stats +): SharedItem | null { + if (type === 'skills') { + if (!stats.isDirectory()) { + return null; + } + + const description = readMarkdownDescription( + path.join(resolvedEntryPath, 'SKILL.md'), + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'skill', + }; + } + + if (stats.isDirectory()) { + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'prompt.md'), + path.join(resolvedEntryPath, 'AGENT.md'), + path.join(resolvedEntryPath, 'agent.md'), + ], + allowedRoots + ); + if (!description) { + return null; + } + + return { + name: entry.name, + description, + path: entryPath, + type: 'agent', + }; + } + + if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) { + return null; + } + + const description = readMarkdownDescription(resolvedEntryPath, allowedRoots); + if (!description) { + return null; + } + + return { + name: entry.name.replace(/\.md$/i, ''), + description, + path: entryPath, + type: 'agent', + }; +} + +interface MarkdownFileEntry { + displayPath: string; + resolvedPath: string; +} + +function collectMarkdownFiles(sharedDir: string, allowedRoots: Set): MarkdownFileEntry[] { + const directoriesToVisit = [sharedDir]; + const visitedDirectories = new Set(); + const markdownFiles: MarkdownFileEntry[] = []; + + while (directoriesToVisit.length > 0) { + const currentDir = directoriesToVisit.pop(); + if (!currentDir) { + continue; + } + + const resolvedCurrentDir = safeRealPath(currentDir); + if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) { + continue; + } + + const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir); + if (visitedDirectories.has(normalizedDirPath)) { + continue; + } + visitedDirectories.add(normalizedDirPath); + + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(currentDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedEntryPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + directoriesToVisit.push(entryPath); + continue; + } + + if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) { + markdownFiles.push({ + displayPath: entryPath, + resolvedPath: resolvedEntryPath, + }); + } } } + + return markdownFiles; +} + +function extractDescription(content: string): string { + const frontmatterDescription = extractFrontmatterDescription(content); + if (frontmatterDescription) { + return trimDescription(frontmatterDescription); + } + + // Extract first non-empty, non-heading line from the markdown body. + const lines = stripFrontmatter(content).split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('