mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +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';
|
} from '../proxy/proxy-target-resolver';
|
||||||
import { loadDrainOrderConfig } from './registry';
|
import { loadDrainOrderConfig } from './registry';
|
||||||
import type { AccountTier } from './types';
|
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. */
|
/** Minimum valid priority value. Management layer treats 0 as delete. */
|
||||||
export const MIN_PRIORITY = 1;
|
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 */
|
/** Management API path for patching auth file fields */
|
||||||
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields';
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mock.module('../../../cliproxy/proxy/proxy-detector', () => ({
|
|||||||
describe('handleOrderSubcommand', () => {
|
describe('handleOrderSubcommand', () => {
|
||||||
let tempHome: string;
|
let tempHome: string;
|
||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalNoColor: string | undefined;
|
||||||
let logSpy: ReturnType<typeof spyOn>;
|
let logSpy: ReturnType<typeof spyOn>;
|
||||||
let lines: string[];
|
let lines: string[];
|
||||||
|
|
||||||
@@ -35,7 +36,16 @@ describe('handleOrderSubcommand', () => {
|
|||||||
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(authDir(), fileName),
|
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 }
|
{ 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<{
|
async function registerClaude(): Promise<{
|
||||||
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
|
registerAccount: (provider: string, tokenFile: string, email: string) => unknown;
|
||||||
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
|
saveDrainOrderConfig: (provider: string, config: unknown) => boolean;
|
||||||
@@ -64,7 +92,9 @@ describe('handleOrderSubcommand', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-'));
|
||||||
originalCcsHome = process.env.CCS_HOME;
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalNoColor = process.env.NO_COLOR;
|
||||||
process.env.CCS_HOME = tempHome;
|
process.env.CCS_HOME = tempHome;
|
||||||
|
process.env.NO_COLOR = '1';
|
||||||
process.exitCode = 0;
|
process.exitCode = 0;
|
||||||
lines = [];
|
lines = [];
|
||||||
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => {
|
||||||
@@ -80,6 +110,11 @@ describe('handleOrderSubcommand', () => {
|
|||||||
} else {
|
} else {
|
||||||
delete process.env.CCS_HOME;
|
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 });
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,5 +199,126 @@ describe('handleOrderSubcommand', () => {
|
|||||||
expect(output).toContain('reset to file order');
|
expect(output).toContain('reset to file order');
|
||||||
expect(output).toContain('no priority set');
|
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 { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry';
|
import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry';
|
||||||
import { getProviderAccounts } from '../../cliproxy/accounts/query';
|
import { getProviderAccounts } from '../../cliproxy/accounts/query';
|
||||||
|
import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||||
import {
|
import {
|
||||||
computeManualDrainOrder,
|
computeManualDrainOrder,
|
||||||
computeTierDrainOrder,
|
computeTierDrainOrder,
|
||||||
@@ -19,6 +20,8 @@ import {
|
|||||||
resolveEffectiveDrainOrder,
|
resolveEffectiveDrainOrder,
|
||||||
clearDrainOrderPriorities,
|
clearDrainOrderPriorities,
|
||||||
tieBreakKey,
|
tieBreakKey,
|
||||||
|
DRAIN_ORDER_MIN_VERSION,
|
||||||
|
isDrainOrderSupported,
|
||||||
type DrainOrderEntry,
|
type DrainOrderEntry,
|
||||||
type DrainOrderInput,
|
type DrainOrderInput,
|
||||||
} from '../../cliproxy/accounts/drain-order';
|
} from '../../cliproxy/accounts/drain-order';
|
||||||
@@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s
|
|||||||
return tierDerived ? label : dim(tier);
|
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(
|
function printOrderTable(
|
||||||
entries: DrainOrderEntry[],
|
entries: DrainOrderEntry[],
|
||||||
showCurrentPriority: boolean,
|
showCurrentPriority: boolean,
|
||||||
@@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise<void> {
|
|||||||
|
|
||||||
const entries = computeTierDrainOrder(accounts);
|
const entries = computeTierDrainOrder(accounts);
|
||||||
|
|
||||||
|
if (!ensureDrainOrderBinarySupport()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(subheader('Computed tier-based drain order:'));
|
console.log(subheader('Computed tier-based drain order:'));
|
||||||
printOrderTable(entries, false);
|
printOrderTable(entries, false);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ensureDrainOrderBinarySupport()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(subheader('Computed manual drain order:'));
|
console.log(subheader('Computed manual drain order:'));
|
||||||
printOrderTable(entries, false);
|
printOrderTable(entries, false);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
Reference in new issue
Block a user