Merge pull request #1730 from kaitranntt/kai/fix-cliproxy-backend-version-gates

fix(cliproxy): backend version compatibility gates and ARM assets (#1724, #1725, #1726)
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-09-09 16:00:33 -06:00
commit 54c6695508
13 files changed
+535 -43

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
"syncFs": {
"totalOccurrences": 2509,
"totalOccurrences": 2513,
"filesAffected": 263,
"hotpathOccurrences": 1183,
"hotpathFilesAffected": 156,
+1 -1
View File
@@ -6,7 +6,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| Metric | Value |
|---|---:|
| Sync fs occurrences (all) | 2509 |
| Sync fs occurrences (all) | 2513 |
| Sync fs files affected (all) | 263 |
| Sync fs occurrences (runtime hotpaths) | 1183 |
| Sync fs files affected (runtime hotpaths) | 156 |
+12 -12
View File
@@ -8,12 +8,12 @@
"src/channels/official-channels-store.ts:291",
"src/channels/official-channels-store.ts:296",
"src/channels/official-channels-store.ts:299",
"src/cliproxy/accounts/drain-order.ts:131",
"src/cliproxy/accounts/drain-order.ts:139",
"src/cliproxy/accounts/drain-order.ts:246",
"src/cliproxy/accounts/drain-order.ts:253",
"src/cliproxy/accounts/drain-order.ts:279",
"src/cliproxy/accounts/drain-order.ts:342",
"src/cliproxy/accounts/drain-order.ts:149",
"src/cliproxy/accounts/drain-order.ts:157",
"src/cliproxy/accounts/drain-order.ts:264",
"src/cliproxy/accounts/drain-order.ts:271",
"src/cliproxy/accounts/drain-order.ts:297",
"src/cliproxy/accounts/drain-order.ts:360",
"src/cliproxy/accounts/registry.ts:521",
"src/cliproxy/accounts/registry.ts:536",
"src/cliproxy/accounts/registry.ts:544",
@@ -38,10 +38,10 @@
"src/cliproxy/binary/installer.ts:74",
"src/cliproxy/binary/installer.ts:88",
"src/cliproxy/binary/lifecycle.ts:137",
"src/cliproxy/binary/platform-detector.ts:153",
"src/cliproxy/binary/platform-detector.ts:160",
"src/cliproxy/binary/platform-detector.ts:152",
"src/cliproxy/binary/platform-detector.ts:159",
"src/cliproxy/binary/verifier.ts:55",
"src/cliproxy/binary/version-checker.ts:108",
"src/cliproxy/binary/version-checker.ts:118",
"src/cliproxy/config/base-config-loader.ts:54",
"src/cliproxy/config/base-config-loader.ts:66",
"src/cliproxy/config/base-config-loader.ts:81",
@@ -59,9 +59,9 @@
"src/cliproxy/executor/proxy-resolver.ts:177",
"src/cliproxy/executor/session-bridge.ts:147",
"src/cliproxy/proxy/https-tunnel-proxy.ts:61",
"src/cliproxy/routing/routing-strategy.ts:480",
"src/cliproxy/routing/routing-strategy.ts:488",
"src/cliproxy/routing/routing-strategy.ts:708",
"src/cliproxy/routing/routing-strategy.ts:490",
"src/cliproxy/routing/routing-strategy.ts:498",
"src/cliproxy/routing/routing-strategy.ts:718",
"src/cliproxy/services/remote-auth-fetcher.ts:139",
"src/cliproxy/services/remote-auth-fetcher.ts:150",
"src/cliproxy/services/remote-auth-fetcher.ts:152",
@@ -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);
});
});
+19 -1
View File
@@ -21,11 +21,29 @@ 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';
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test';
import { compareCliproxyVersions, isNewerVersion } from '../version-checker';
import {
type CLIProxyBackendMinVersions,
compareCliproxyVersions,
isNewerVersion,
meetsBackendMinimumVersion,
} from '../version-checker';
describe('cliproxy version comparison', () => {
it('treats missing fork release suffix as zero', () => {
@@ -19,3 +24,38 @@ describe('cliproxy version comparison', () => {
expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false);
});
});
describe('meetsBackendMinimumVersion', () => {
const testMinimums: CLIProxyBackendMinVersions = {
original: '7.2.104',
plus: '7.2.105-0',
};
it('evaluates original backend boundary correctly', () => {
expect(meetsBackendMinimumVersion('7.2.103', 'original', testMinimums)).toBe(false);
expect(meetsBackendMinimumVersion('7.2.104', 'original', testMinimums)).toBe(true);
expect(meetsBackendMinimumVersion('7.2.105', 'original', testMinimums)).toBe(true);
});
it('evaluates plus backend boundary with fork suffixes correctly', () => {
expect(meetsBackendMinimumVersion('7.2.104-9', 'plus', testMinimums)).toBe(false);
expect(meetsBackendMinimumVersion('7.2.105-0', 'plus', testMinimums)).toBe(true);
expect(meetsBackendMinimumVersion('7.2.105-1', 'plus', testMinimums)).toBe(true);
});
it('treats missing fork suffix as equal to -0', () => {
const minWithSuffix: CLIProxyBackendMinVersions = {
original: '6.8.34-0',
plus: '6.8.34-0',
};
expect(meetsBackendMinimumVersion('6.8.34', 'original', minWithSuffix)).toBe(true);
expect(meetsBackendMinimumVersion('6.8.34', 'plus', minWithSuffix)).toBe(true);
});
it('rejects empty and invalid version strings against real minimums', () => {
expect(meetsBackendMinimumVersion('', 'original', testMinimums)).toBe(false);
expect(meetsBackendMinimumVersion('not-a-version', 'original', testMinimums)).toBe(false);
expect(meetsBackendMinimumVersion('', 'plus', testMinimums)).toBe(false);
expect(meetsBackendMinimumVersion('not-a-version', 'plus', testMinimums)).toBe(false);
});
});
+5 -6
View File
@@ -84,7 +84,10 @@ const RELEASE_ARCH_MAP: Record<SupportedArch, SupportedArch> = {
};
const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0';
const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1';
const AARCH64_ASSET_MIN_VERSION: Readonly<Record<CLIProxyBackend, string>> = {
original: '6.9.48',
plus: '6.10.0-0',
};
export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined {
const arch = ARCH_MAP[nodeArch];
@@ -121,11 +124,7 @@ function getReleaseArchForBackend(
publicArch: SupportedArch,
releaseArch: SupportedArch
): SupportedArch {
if (
backend === 'plus' &&
publicArch === 'arm64' &&
!isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION)
) {
if (publicArch === 'arm64' && !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend])) {
return 'arm64';
}
+10
View File
@@ -21,6 +21,8 @@ import {
} from '../binary/platform-detector';
import type { CLIProxyBackend } from '../types';
export type CLIProxyBackendMinVersions = Readonly<Record<CLIProxyBackend, string>>;
interface FetchLatestVersionDeps {
fetchJsonFn?: typeof fetchJson;
}
@@ -78,6 +80,14 @@ export function isNewerVersion(latest: string, current: string): boolean {
return compareCliproxyVersions(latest, current) > 0;
}
export function meetsBackendMinimumVersion(
installedVersion: string,
backend: CLIProxyBackend,
minimumByBackend: CLIProxyBackendMinVersions
): boolean {
return compareCliproxyVersions(installedVersion, minimumByBackend[backend]) >= 0;
}
/**
* Check if version is within the faulty range (v81-85)
* @returns true if version has known critical bugs
@@ -86,13 +86,102 @@ describe('Backend Selection', () => {
);
});
});
it('generates correct binary name for original backend', () => {
const info = platformDetector.detectPlatform('6.6.51', 'original');
assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_'));
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
});
it('selects arm64 vs aarch64 release assets across original and plus boundaries', () => {
// Original pre-rename boundaries (<= 6.9.47) use arm64
for (const version of [undefined, '6.9.45', '6.9.47']) {
const effectiveVersion =
version || platformDetector.BACKEND_CONFIG.original.fallbackVersion;
withMockedProcessPlatform('darwin', 'arm64', () => {
const info = platformDetector.detectPlatform(version, 'original');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(
info.binaryName,
`CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`
);
assert.strictEqual(
platformDetector.getDownloadUrl(version, 'original'),
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`
);
});
withMockedProcessPlatform('linux', 'arm64', () => {
const info = platformDetector.detectPlatform(version, 'original');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`);
assert.strictEqual(
platformDetector.getDownloadUrl(version, 'original'),
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`
);
});
withMockedProcessPlatform('win32', 'arm64', () => {
const info = platformDetector.detectPlatform(version, 'original');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`);
assert.strictEqual(
platformDetector.getDownloadUrl(version, 'original'),
`https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`
);
});
}
// Original post-rename boundary (>= 6.9.48) uses aarch64 while public arch remains arm64
withMockedProcessPlatform('darwin', 'arm64', () => {
const info = platformDetector.detectPlatform('6.9.48', 'original');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz');
assert.strictEqual(
platformDetector.getDownloadUrl('6.9.48', 'original'),
'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.9.48/CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz'
);
});
// Plus fallback (undefined) and explicit pre-rename (< 6.10.0-0) use arm64
for (const version of [undefined, '6.9.45-0']) {
const effectiveVersion = version || platformDetector.BACKEND_CONFIG.plus.fallbackVersion;
withMockedProcessPlatform('darwin', 'arm64', () => {
const info = platformDetector.detectPlatform(version, 'plus');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(
info.binaryName,
`CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz`
);
assert.strictEqual(
platformDetector.getDownloadUrl(version, 'plus'),
`https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v${effectiveVersion}/CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz`
);
});
}
// Plus post-rename (>= 6.10.0-0) uses aarch64
withMockedProcessPlatform('darwin', 'arm64', () => {
const info = platformDetector.detectPlatform('6.10.0-0', 'plus');
assert.strictEqual(info.arch, 'arm64');
assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz');
assert.strictEqual(
platformDetector.getDownloadUrl('6.10.0-0', 'plus'),
'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v6.10.0-0/CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz'
);
});
// x64 remains amd64 for both backends
withMockedProcessPlatform('linux', 'x64', () => {
assert.strictEqual(
platformDetector.detectPlatform('6.9.45', 'original').binaryName,
'CLIProxyAPI_6.9.45_linux_amd64.tar.gz'
);
assert.strictEqual(
platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName,
'CLIProxyAPIPlus_6.9.45-0_linux_amd64.tar.gz'
);
});
});
it('uses static no-plugin assets when the integrated Linux image opts in', () => {
withEnvironmentVariable('CCS_CLIPROXY_NO_PLUGIN_ASSET', '1', () => {
withMockedProcessPlatform('linux', 'x64', () => {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { CLIProxyBackend } from '../../types';
describe('cliproxy routing strategy service', () => {
let tempHome = '';
let scopedConfigDir = '';
@@ -15,6 +15,11 @@ describe('cliproxy routing strategy service', () => {
protocol: 'http' as const,
isRemote: false,
};
let binaryBackend: CLIProxyBackend = 'original';
let binaryVersion = '7.2.127-7';
let installedVersionQueriedBackend: CLIProxyBackend | undefined = undefined;
let loggedWarnings: Array<{ event: string; message: string; context?: Record<string, unknown> }> =
[];
let responseFactory: (() => Promise<Response>) | null = null;
beforeEach(async () => {
@@ -28,6 +33,10 @@ describe('cliproxy routing strategy service', () => {
};
responseFactory = null;
originalCcsDir = process.env.CCS_DIR;
binaryBackend = 'original';
binaryVersion = '7.2.127-7';
installedVersionQueriedBackend = undefined;
loggedWarnings = [];
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_DIR = scopedConfigDir;
process.env.CCS_HOME = tempHome;
@@ -74,6 +83,26 @@ describe('cliproxy routing strategy service', () => {
},
}));
mock.module('../../binary-manager', () => ({
getConfiguredBackend: () => binaryBackend,
getInstalledCliproxyVersion: (backend?: CLIProxyBackend) => {
installedVersionQueriedBackend = backend;
return binaryVersion;
},
}));
mock.module('../../../services/logging', () => ({
createLogger: () => ({
debug: () => {},
info: () => {},
warn: (event: string, message: string, context?: Record<string, unknown>) => {
loggedWarnings.push({ event, message, context });
},
error: () => {},
child: () => ({ warn: () => {} }),
}),
}));
return import(`../routing-strategy?test=${Date.now()}-${Math.random()}`);
}
@@ -365,4 +394,65 @@ describe('cliproxy routing strategy service', () => {
expect(result.message).not.toContain('Pool routing is active');
});
});
describe('pool routing version compatibility (#1726)', () => {
it('defines backend-specific minimum versions for pool routing', async () => {
const mod = await loadRoutingModule();
expect(mod.POOL_ROUTING_MIN_VERSION).toEqual({
original: '6.8.34',
plus: '6.8.34-0',
});
});
it('evaluates isPoolRoutingSupported correctly across original and plus thresholds', async () => {
const mod = await loadRoutingModule();
expect(mod.isPoolRoutingSupported('original', '6.8.33')).toBe(false);
expect(mod.isPoolRoutingSupported('original', '6.8.34')).toBe(true);
expect(mod.isPoolRoutingSupported('original', '6.9.0')).toBe(true);
expect(mod.isPoolRoutingSupported('plus', '6.8.33-9')).toBe(false);
expect(mod.isPoolRoutingSupported('plus', '6.8.34-0')).toBe(true);
expect(mod.isPoolRoutingSupported('plus', '7.2.127-7')).toBe(true);
expect(mod.isPoolRoutingSupported('original', '')).toBe(false);
expect(mod.isPoolRoutingSupported('plus', 'not-a-version')).toBe(false);
});
it('warns with backend-specific metadata when plus binary is below minimum', async () => {
await withScopedConfig(async () => {
binaryBackend = 'plus';
binaryVersion = '6.8.33-9';
loggedWarnings = [];
const mod = await loadRoutingModule();
const result = mod.enablePoolRouting(8317);
expect(result.changed).toBe(true);
expect(installedVersionQueriedBackend).toBe('plus');
expect(loggedWarnings).toHaveLength(1);
expect(loggedWarnings[0].event).toBe('pool_routing.binary_below_minimum');
expect(loggedWarnings[0].message).toContain('CLIProxy Plus');
expect(loggedWarnings[0].context).toEqual({
backend: 'plus',
installedVersion: '6.8.33-9',
minimumVersion: '6.8.34-0',
});
});
});
it('does not warn when plus binary meets the minimum version', async () => {
await withScopedConfig(async () => {
binaryBackend = 'plus';
binaryVersion = '6.8.34-0';
loggedWarnings = [];
const mod = await loadRoutingModule();
const result = mod.enablePoolRouting(8317);
expect(result.changed).toBe(true);
expect(installedVersionQueriedBackend).toBe('plus');
expect(loggedWarnings).toHaveLength(0);
});
});
});
});
+24 -14
View File
@@ -7,10 +7,13 @@ import {
getCliproxyRoutingTarget,
getRoutingErrorMessage,
} from './routing-strategy-http';
import type { CliproxyRoutingStrategy } from '../types';
import type { CLIProxyBackend, CliproxyRoutingStrategy } from '../types';
import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade';
import { getInstalledCliproxyVersion } from '../binary-manager';
import { compareVersions } from '../../utils/update-checker';
import { getConfiguredBackend, getInstalledCliproxyVersion } from '../binary-manager';
import {
type CLIProxyBackendMinVersions,
meetsBackendMinimumVersion,
} from '../binary/version-checker';
import { getConfigYamlPath } from '../../config/loader/io-locks';
import { createLogger } from '../../services/logging';
@@ -46,12 +49,18 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']);
* max-retry-credentials and the cooling flip.
* Older binaries silently ignore unknown keys — pool rails would appear active
* but have no effect. Warn the user at enable time if below this version.
*
* NOTE: Update this constant when upstream first ships these keys.
* Current best estimate based on spec; adjust after spike Test D confirms.
*/
export const POOL_ROUTING_MIN_VERSION = '6.9.45';
export const POOL_ROUTING_MIN_VERSION: CLIProxyBackendMinVersions = {
original: '6.8.34',
plus: '6.8.34-0',
};
export function isPoolRoutingSupported(
backend: CLIProxyBackend,
installedVersion: string
): boolean {
return meetsBackendMinimumVersion(installedVersion, backend, POOL_ROUTING_MIN_VERSION);
}
/**
* Pool-active override warning text. When pool routing is enabled the generator
* forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so
@@ -205,20 +214,21 @@ export function enablePoolRouting(
const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity();
// Spec step 3 / architecture: assert minimum CLIProxy version at enable time.
// Stale binaries silently ignore max-retry-credentials and the cooling flip,
// so pool rails would appear active but have no effect. Warn and proceed.
try {
const installedVersion = getInstalledCliproxyVersion();
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
const backend = getConfiguredBackend();
const installedVersion = getInstalledCliproxyVersion(backend);
if (!isPoolRoutingSupported(backend, installedVersion)) {
const minimumVersion = POOL_ROUTING_MIN_VERSION[backend];
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
logger.warn(
'pool_routing.binary_below_minimum',
`CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
`${backendLabel} v${installedVersion} is older than the pool routing minimum (v${minimumVersion}). ` +
`The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` +
`Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`,
{
backend,
installedVersion,
minimumVersion: POOL_ROUTING_MIN_VERSION,
minimumVersion,
}
);
}
@@ -19,11 +19,14 @@ import * as path from 'path';
mock.module('../../../cliproxy/proxy/proxy-detector', () => ({
detectRunningProxy: async () => ({ running: false, verified: false }),
waitForProxyHealthy: async () => ({ running: false, verified: false }),
reclaimOrphanedProxy: () => {},
}));
describe('handleOrderSubcommand', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let originalNoColor: string | undefined;
let logSpy: ReturnType<typeof spyOn>;
let lines: string[];
@@ -35,7 +38,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 +59,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 +94,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,14 +112,18 @@ 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 });
});
describe('file-mode show with residual priorities', () => {
it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => {
// Residual on-disk priorities, no stored config -> file mode + drift.
// claude-a sorts first alphabetically, but b has the higher priority, so
// the selector drains b first. The display must follow the selector.
// Explicit below-minimum version to prove show is ungated on old binaries.
await configureBackend('original', '6.6.105');
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 });
writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 });
@@ -96,6 +132,7 @@ describe('handleOrderSubcommand', () => {
registerAccount('claude', 'claude-b.json', 'b@x.com');
await runOrderSubcommand(['claude']);
expect(process.exitCode).toBe(0);
const output = lines.join('\n');
// b@x.com (priority 5) must appear before a@x.com (priority 1).
@@ -105,7 +142,6 @@ describe('handleOrderSubcommand', () => {
expect(idxA).toBeGreaterThan(idxB);
// Drift surfaced (same as the manual/tier branch), and the mode label no
// longer falsely claims "no priority set" under residual priorities.
expect(output).toContain('Drift detected');
expect(output).toContain('residual priorities present');
expect(output).not.toContain('no priority set');
@@ -115,6 +151,8 @@ describe('handleOrderSubcommand', () => {
});
it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => {
// Explicit below-minimum version on Plus to prove show is ungated on old binaries.
await configureBackend('plus', '6.6.105-0');
writeAuthFile('claude-a.json', { email: 'a@x.com' });
writeAuthFile('claude-b.json', { email: 'b@x.com' });
@@ -123,6 +161,7 @@ describe('handleOrderSubcommand', () => {
registerAccount('claude', 'claude-b.json', 'b@x.com');
await runOrderSubcommand(['claude']);
expect(process.exitCode).toBe(0);
const output = lines.join('\n');
expect(output).toContain('no priority set');
@@ -132,6 +171,8 @@ describe('handleOrderSubcommand', () => {
describe('--reset clears residual priorities (proxy stopped -> direct write)', () => {
it('removes the priority field from auth files and reports per-file results', async () => {
// Explicit below-minimum version to prove reset is ungated on old binaries.
await configureBackend('original', '6.6.105');
writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 });
writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear
@@ -141,6 +182,7 @@ describe('handleOrderSubcommand', () => {
saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] });
await runOrderSubcommand(['claude', '--reset']);
expect(process.exitCode).toBe(0);
// Residual priority is actually gone from disk (not just config deleted).
expect('priority' in readAuthFile('claude-a.json')).toBe(false);
@@ -153,16 +195,146 @@ describe('handleOrderSubcommand', () => {
});
it('reports already-clear files and still resets when no priorities exist', async () => {
// Explicit below-minimum version on Plus to prove reset is ungated on old binaries.
await configureBackend('plus', '6.6.105-0');
writeAuthFile('claude-a.json', { email: 'a@x.com' });
const { registerAccount } = await registerClaude();
registerAccount('claude', 'claude-a.json', 'a@x.com');
await runOrderSubcommand(['claude', '--reset']);
expect(process.exitCode).toBe(0);
const output = lines.join('\n');
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('');