fix(cliproxy): fail-closed on unsupported drain-order priority selection (#1724)

This commit is contained in:
Tam Nhu Tran committed 2026-09-02 11:14:57 -04:00
1 parent b9601cb913
commit ed1badcfcb
4 files changed
+243 -2

No files matched your search

@@ -933,3 +933,36 @@ describe('fetchProxyAuthCooldowns', () => {
});
});
});
describe('DRAIN_ORDER_MIN_VERSION and isDrainOrderSupported (#1724)', () => {
it('defines backend-specific minimum versions for drain-order priorities', async () => {
const { DRAIN_ORDER_MIN_VERSION } = await loadDrainOrder();
expect(DRAIN_ORDER_MIN_VERSION).toEqual({
original: '6.6.106',
plus: '6.6.107-0',
});
});
it('evaluates original backend boundary correctly', async () => {
const { isDrainOrderSupported } = await loadDrainOrder();
expect(isDrainOrderSupported('original', '6.6.105')).toBe(false);
expect(isDrainOrderSupported('original', '6.6.106')).toBe(true);
expect(isDrainOrderSupported('original', '6.7.0')).toBe(true);
});
it('evaluates plus backend boundary with fork suffixes correctly', async () => {
const { isDrainOrderSupported } = await loadDrainOrder();
expect(isDrainOrderSupported('plus', '6.6.105-9')).toBe(false);
expect(isDrainOrderSupported('plus', '6.6.106-0')).toBe(false);
expect(isDrainOrderSupported('plus', '6.6.107-0')).toBe(true);
expect(isDrainOrderSupported('plus', '7.2.127-7')).toBe(true);
});
it('rejects invalid and empty version strings', async () => {
const { isDrainOrderSupported } = await loadDrainOrder();
expect(isDrainOrderSupported('original', '')).toBe(false);
expect(isDrainOrderSupported('original', 'invalid')).toBe(false);
expect(isDrainOrderSupported('plus', '')).toBe(false);
expect(isDrainOrderSupported('plus', 'invalid')).toBe(false);
});
});
+22 -1
View File
@@ -21,11 +21,32 @@ import {
} from '../proxy/proxy-target-resolver';
import { loadDrainOrderConfig } from './registry';
import type { AccountTier } from './types';
import type { CLIProxyProvider } from '../types';
import type { CLIProxyBackend, CLIProxyProvider } from '../types';
import {
type CLIProxyBackendMinVersions,
meetsBackendMinimumVersion,
} from '../binary/version-checker';
/** Minimum valid priority value. Management layer treats 0 as delete. */
export const MIN_PRIORITY = 1;
/**
* Minimum CLIProxy version that supports drain-order priority selection.
* Original: v6.6.106
* Plus: v6.6.107-0
*/
export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = {
original: '6.6.106',
plus: '6.6.107-0',
};
export function isDrainOrderSupported(
backend: CLIProxyBackend,
installedVersion: string
): boolean {
return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION);
}
/** Management API path for patching auth file fields */
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
@@ -24,6 +24,7 @@ mock.module('../../../cliproxy/proxy/proxy-detector', () => ({
describe('handleOrderSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let originalNoColor: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
@@ -35,7 +36,16 @@ describe('handleOrderSubcommand', () => {
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir(), fileName),
JSON.stringify({ type: 'claude', ...fields }, null, 2),
JSON.stringify({ type: 'claude', email: fileName, ...fields }, null, 2),
{ mode: 0o600 }
);
}
function writeAgyAuthFile(fileName: string, fields: Record<string, unknown> = {}): void {
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(authDir(), fileName),
JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2),
{ mode: 0o600 }
);
}
@@ -47,6 +57,24 @@ describe('handleOrderSubcommand', () => {
>;
}
async function loadRegistry() {
return import(`../../../cliproxy/accounts/registry?order-subcommand-reg=${Date.now()}`);
}
async function configureBackend(backend: 'original' | 'plus', version: string): Promise<void> {
const { mutateConfig, invalidateConfigCache } = await import(
'../../../config/config-loader-facade'
);
mutateConfig((cfg) => {
if (!cfg.cliproxy) cfg.cliproxy = {};
cfg.cliproxy.backend = backend;
});
invalidateConfigCache();
const verPath = path.join(tempHome, '.ccs', 'cliproxy', 'bin', backend, '.version');
fs.mkdirSync(path.dirname(verPath), { recursive: true });
fs.writeFileSync(verPath, version.trim() + '\n', 'utf-8');
}
async function registerClaude(): Promise<{
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
@@ -64,7 +92,9 @@ describe('handleOrderSubcommand', () => {
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
originalCcsHome = process.env.CCS_HOME;
originalNoColor = process.env.NO_COLOR;
process.env.CCS_HOME = tempHome;
process.env.NO_COLOR = '1';
process.exitCode = 0;
lines = [];
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
@@ -80,6 +110,11 @@ describe('handleOrderSubcommand', () => {
} else {
delete process.env.CCS_HOME;
}
if (originalNoColor !== undefined) {
process.env.NO_COLOR = originalNoColor;
} else {
delete process.env.NO_COLOR;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
@@ -164,5 +199,126 @@ describe('handleOrderSubcommand', () => {
expect(output).toContain('reset to file order');
expect(output).toContain('no priority set');
});
});
describe('binary version capability gate (#1724)', () => {
const cases: Array<{
backend: 'original' | 'plus';
belowMinVersion: string;
atMinVersion: string;
backendLabel: string;
requiredMinVersion: string;
}> = [
{
backend: 'original',
belowMinVersion: '6.6.105',
atMinVersion: '6.6.106',
backendLabel: 'CLIProxy',
requiredMinVersion: '6.6.106',
},
{
backend: 'plus',
belowMinVersion: '6.6.105-0',
atMinVersion: '6.6.107-0',
backendLabel: 'CLIProxy Plus',
requiredMinVersion: '6.6.107-0',
},
];
for (const { backend, belowMinVersion, atMinVersion, backendLabel, requiredMinVersion } of cases) {
describe(`${backend} backend`, () => {
it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => {
await configureBackend(backend, belowMinVersion);
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount, loadDrainOrderConfig } = await loadRegistry();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']);
expect(process.exitCode).toBe(1);
const output = lines.join('\n');
expect(output).toContain('[X]');
expect(output).toContain(
`${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).`
);
expect(output).toContain(
`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`
);
expect(output).not.toContain('Set priorities');
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
expect('priority' in readAuthFile('claude-b.json')).toBe(false);
expect(loadDrainOrderConfig('claude')).toBeUndefined();
});
it(`refuses --by-tier below minimum version (${belowMinVersion}) without mutating files`, async () => {
await configureBackend(backend, belowMinVersion);
writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' });
writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' });
const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry();
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
setAccountTier('agy', 'a@x.com', 'pro');
setAccountTier('agy', 'b@x.com', 'free');
await runOrderSubcommand(['agy', '--by-tier']);
expect(process.exitCode).toBe(1);
const output = lines.join('\n');
expect(output).toContain('[X]');
expect(output).toContain(
`${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).`
);
expect(output).toContain(
`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`
);
expect(output).not.toContain('Set priorities');
expect('priority' in readAuthFile('antigravity-a.json')).toBe(false);
expect('priority' in readAuthFile('antigravity-b.json')).toBe(false);
expect(loadDrainOrderConfig('agy')).toBeUndefined();
});
it(`applies and persists --set at minimum version (${atMinVersion})`, async () => {
await configureBackend(backend, atMinVersion);
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
const { registerAccount, loadDrainOrderConfig } = await loadRegistry();
registerAccount('claude', 'claude-a.json', 'a@x.com');
registerAccount('claude', 'claude-b.json', 'b@x.com');
await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']);
expect(process.exitCode).toBe(0);
const output = lines.join('\n');
expect(output).toContain('Set priorities for 2 account(s).');
expect('priority' in readAuthFile('claude-a.json')).toBe(true);
expect(loadDrainOrderConfig('claude')?.mode).toBe('manual');
});
it(`applies and persists --by-tier at minimum version (${atMinVersion})`, async () => {
await configureBackend(backend, atMinVersion);
writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' });
writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' });
const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry();
registerAccount('agy', 'antigravity-a.json', 'a@x.com');
registerAccount('agy', 'antigravity-b.json', 'b@x.com');
setAccountTier('agy', 'a@x.com', 'pro');
setAccountTier('agy', 'b@x.com', 'free');
await runOrderSubcommand(['agy', '--by-tier']);
expect(process.exitCode).toBe(0);
const output = lines.join('\n');
expect(output).toContain('Set priorities for 2 account(s).');
expect('priority' in readAuthFile('antigravity-a.json')).toBe(true);
expect(loadDrainOrderConfig('agy')?.mode).toBe('tier');
});
});
}
});
});
+31
View File
@@ -12,6 +12,7 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry';
import { getProviderAccounts } from '../../cliproxy/accounts/query';
import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
import {
computeManualDrainOrder,
computeTierDrainOrder,
@@ -19,6 +20,8 @@ import {
resolveEffectiveDrainOrder,
clearDrainOrderPriorities,
tieBreakKey,
DRAIN_ORDER_MIN_VERSION,
isDrainOrderSupported,
type DrainOrderEntry,
type DrainOrderInput,
} from '../../cliproxy/accounts/drain-order';
@@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s
return tierDerived ? label : dim(tier);
}
function ensureDrainOrderBinarySupport(): boolean {
const backend = getConfiguredBackend();
const installedVersion = getInstalledCliproxyVersion(backend);
if (!isDrainOrderSupported(backend, installedVersion)) {
const minimumVersion = DRAIN_ORDER_MIN_VERSION[backend];
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
console.log(
fail(
`${backendLabel} v${installedVersion} does not support drain-order priorities (requires v${minimumVersion} or newer).`
)
);
console.log(
info(`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`)
);
console.log('');
process.exitCode = 1;
return false;
}
return true;
}
function printOrderTable(
entries: DrainOrderEntry[],
showCurrentPriority: boolean,
@@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise<void> {
const entries = computeTierDrainOrder(accounts);
if (!ensureDrainOrderBinarySupport()) {
return;
}
console.log(subheader('Computed tier-based drain order:'));
printOrderTable(entries, false);
console.log('');
@@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
return;
}
if (!ensureDrainOrderBinarySupport()) {
return;
}
console.log(subheader('Computed manual drain order:'));
printOrderTable(entries, false);
console.log('');