mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 13:12:09 +00:00
fix(cliproxy): fail-closed on unsupported drain-order priority selection (#1724)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
|
||||
Reference in new issue
Block a user