diff --git a/package.json b/package.json index dcbc0eec..edefe896 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.77.0", + "version": "7.77.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index 9c1d2f9f..eeb7c9f4 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -64,9 +64,7 @@ export const CLIPROXY_FAULTY_RANGE = { min: '6.6.81-0', max: '6.6.88-0' }; /** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */ export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION; -/** - * Platform mapping from Node.js values to CLIProxyAPI naming - */ +/** Platform mapping from Node.js values to CCS public architecture labels. */ const OS_MAP: Record = { darwin: 'darwin', linux: 'linux', @@ -78,6 +76,18 @@ const ARCH_MAP: Record = { arm64: 'arm64', }; +/** CLIProxy release assets use aarch64 while Node.js reports arm64. */ +const RELEASE_ARCH_MAP: Record = { + amd64: 'amd64', + arm64: 'aarch64', + aarch64: 'aarch64', +}; + +export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined { + const arch = ARCH_MAP[nodeArch]; + return arch ? RELEASE_ARCH_MAP[arch] : undefined; +} + /** * Detect current platform and return binary info * @param version Optional version for binaryName (defaults to backend fallback) @@ -93,6 +103,7 @@ export function detectPlatform( const os = OS_MAP[nodePlatform]; const arch = ARCH_MAP[nodeArch]; + const releaseArch = mapNodeArchToReleaseArch(nodeArch); if (!os) { throw new Error( @@ -101,16 +112,16 @@ export function detectPlatform( ); } - if (!arch) { + if (!arch || !releaseArch) { throw new Error( - `Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64` + `Unsupported CPU architecture: ${nodeArch}\n` + `Supported: x64 (amd64), arm64 (aarch64)` ); } const config = BACKEND_CONFIG[backend]; const ver = version || config.fallbackVersion; const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; - const binaryName = `${config.binaryPrefix}_${ver}_${os}_${arch}.${extension}`; + const binaryName = `${config.binaryPrefix}_${ver}_${os}_${releaseArch}.${extension}`; return { os, @@ -195,14 +206,16 @@ export function isPlatformSupported(): boolean { /** * Get human-readable platform description - * @returns Description string (e.g., "macOS arm64") + * @returns Description string (e.g., "macOS ARM64") */ export function getPlatformDescription(): string { try { const platform = detectPlatform(); const osName = platform.os === 'darwin' ? 'macOS' : platform.os === 'linux' ? 'Linux' : 'Windows'; - return `${osName} ${platform.arch}`; + const archName = + platform.arch === 'arm64' || platform.arch === 'aarch64' ? 'ARM64' : platform.arch; + return `${osName} ${archName}`; } catch { return `${process.platform} ${process.arch} (unsupported)`; } diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index 5c547984..b4d1ca11 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -9,6 +9,21 @@ describe('Backend Selection', () => { const platformDetector = require('../../../../dist/cliproxy/binary/platform-detector'); const types = require('../../../../dist/cliproxy/types'); + function withMockedProcessPlatform(platform, arch, callback) { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + const originalArch = Object.getOwnPropertyDescriptor(process, 'arch'); + + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + Object.defineProperty(process, 'arch', { value: arch, configurable: true }); + + try { + callback(); + } finally { + Object.defineProperty(process, 'platform', originalPlatform); + Object.defineProperty(process, 'arch', originalArch); + } + } + describe('BACKEND_CONFIG', () => { it('has correct configuration for original backend', () => { const config = platformDetector.BACKEND_CONFIG.original; @@ -34,6 +49,24 @@ describe('Backend Selection', () => { }); describe('detectPlatform', () => { + it('maps Node arm64 to CLIProxy aarch64 release assets', () => { + assert.strictEqual(platformDetector.mapNodeArchToReleaseArch('arm64'), 'aarch64'); + assert.strictEqual(platformDetector.mapNodeArchToReleaseArch('x64'), 'amd64'); + }); + + it('keeps public ARM64 arch compatibility while using aarch64 release assets', () => { + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.10.4', 'original'); + + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.10.4_darwin_aarch64.tar.gz'); + assert.strictEqual( + platformDetector.getDownloadUrl('6.10.4', 'original'), + 'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.10.4/CLIProxyAPI_6.10.4_darwin_aarch64.tar.gz' + ); + }); + }); + it('generates correct binary name for original backend', () => { const info = platformDetector.detectPlatform('6.6.51', 'original'); assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_')); diff --git a/src/cliproxy/types/__tests__/types-backward-compat.test.ts b/src/cliproxy/types/__tests__/types-backward-compat.test.ts index 5ee676ad..95952fb7 100644 --- a/src/cliproxy/types/__tests__/types-backward-compat.test.ts +++ b/src/cliproxy/types/__tests__/types-backward-compat.test.ts @@ -35,6 +35,7 @@ describe('types.ts backward compatibility', () => { it('exports all platform types', () => { const os: SupportedOS = 'darwin'; const arch: SupportedArch = 'arm64'; + const releaseArch: SupportedArch = 'aarch64'; const ext: ArchiveExtension = 'tar.gz'; const info: PlatformInfo = { os, @@ -44,6 +45,7 @@ describe('types.ts backward compatibility', () => { }; expect(info.os).toBe('darwin'); expect(info.arch).toBe('arm64'); + expect(releaseArch).toBe('aarch64'); }); it('exports all binary types', () => { diff --git a/src/cliproxy/types/platform-types.ts b/src/cliproxy/types/platform-types.ts index 0fdd0f65..84f27d2d 100644 --- a/src/cliproxy/types/platform-types.ts +++ b/src/cliproxy/types/platform-types.ts @@ -5,8 +5,8 @@ /** Supported operating systems */ export type SupportedOS = 'darwin' | 'linux' | 'windows'; -/** Supported CPU architectures */ -export type SupportedArch = 'amd64' | 'arm64'; +/** Supported CPU architecture labels from Node.js and CLIProxy release assets */ +export type SupportedArch = 'amd64' | 'arm64' | 'aarch64'; /** Archive extension based on platform */ export type ArchiveExtension = 'tar.gz' | 'zip'; diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 79be4082..292ce5dc 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -23,6 +23,8 @@ export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatMode const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models'; const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; +const CURSOR_MODELS_MAX_BODY_SIZE = 1024 * 1024; // 1MB limit +const CURSOR_DAEMON_MODELS_TIMEOUT_MS = 5000; let liveModelsCache: { models: CursorModel[]; @@ -81,6 +83,14 @@ function parseApiModelsResponse(payload: unknown): CursorModel[] | null { return models.length > 0 ? models : null; } +function parseContentLength(value: string | string[] | undefined): number | null { + const rawValue = Array.isArray(value) ? value[0] : value; + if (!rawValue) return null; + + const parsed = Number.parseInt(rawValue, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null { if (!liveModelsCache) return null; if (liveModelsCache.expiresAtMs <= nowMs) { @@ -195,24 +205,39 @@ export async function fetchModelsFromDaemon(port: number): Promise { - const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit + const contentLength = parseContentLength(res.headers['content-length']); + if (contentLength !== null && contentLength > CURSOR_MODELS_MAX_BODY_SIZE) { + debugLog( + 'Cursor daemon /v1/models content-length exceeded 1MB; falling back to defaults' + ); + res.destroy(); + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + return; + } + let data = ''; + let bodySize = 0; res.on('data', (chunk) => { - data += chunk; - if (data.length > MAX_BODY_SIZE) { + if (resolved) return; + bodySize += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + if (bodySize > CURSOR_MODELS_MAX_BODY_SIZE) { debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults'); res.destroy(); req.destroy(); safeResolve(DEFAULT_CURSOR_MODELS); return; } + + data += chunk; }); res.on('end', () => { + if (resolved) return; try { const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> }; if (Array.isArray(response.data)) { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index dddea9e8..14569c52 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -170,7 +170,7 @@ describe('fetchModelsFromDaemon', () => { it( 'falls back to defaults when daemon response exceeds max body size', async () => { - const oversizedPayload = 'x'.repeat(1024 * 1024 + 1024); + const oversizedPayload = Buffer.alloc(1024 * 1024 + 1024, 'x'); const server = http.createServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(oversizedPayload); @@ -187,11 +187,37 @@ describe('fetchModelsFromDaemon', () => { expect(models).toEqual(DEFAULT_CURSOR_MODELS); } finally { server.closeAllConnections?.(); + server.closeIdleConnections?.(); await new Promise((resolve) => server.close(() => resolve())); } }, 30000 ); + + it('falls back to defaults when daemon advertises an oversized response', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': String(1024 * 1024 + 1), + }); + res.flushHeaders(); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + } finally { + server.closeAllConnections?.(); + server.closeIdleConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); describe('fetchModelsFromCursorApi', () => {