mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(cliproxy): avoid network-bound local startup
- skip CLIProxy auto-update checks on runtime bootstrap paths - fail fast when local startup needs a missing binary instead of attempting installs - add regression coverage for dashboard limited mode and startup test isolation
This commit is contained in:
@@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise<
|
||||
|
||||
try {
|
||||
log('Ensuring CLIProxy binary is available...');
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
const configPath = generateConfig('kiro');
|
||||
|
||||
log(`Binary: ${binaryPath}`);
|
||||
|
||||
@@ -428,7 +428,7 @@ async function prepareBinary(
|
||||
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
|
||||
|
||||
try {
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
process.stdout.write('\x1b[1A\x1b[2K');
|
||||
showStep(1, 4, 'ok', 'CLIProxy binary ready');
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary
|
||||
maxRetries: 3,
|
||||
verbose: false,
|
||||
forceVersion: false,
|
||||
skipAutoUpdate: false,
|
||||
allowInstall: true,
|
||||
backend, // Pass backend for installer to use correct download URL
|
||||
};
|
||||
}
|
||||
@@ -115,8 +117,16 @@ export class BinaryManager {
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureCLIProxyBinaryOptions {
|
||||
allowInstall?: boolean;
|
||||
skipAutoUpdate?: boolean;
|
||||
}
|
||||
|
||||
/** Convenience function respecting version pin */
|
||||
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
|
||||
export async function ensureCLIProxyBinary(
|
||||
verbose = false,
|
||||
options: EnsureCLIProxyBinaryOptions = {}
|
||||
): Promise<string> {
|
||||
const backend = getConfiguredBackend();
|
||||
|
||||
// Migrate old shared pin to backend-specific location (one-time migration)
|
||||
@@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
|
||||
version: pinnedVersion,
|
||||
verbose,
|
||||
forceVersion: true,
|
||||
skipAutoUpdate: options.skipAutoUpdate ?? false,
|
||||
allowInstall: options.allowInstall ?? true,
|
||||
},
|
||||
backend
|
||||
).ensureBinary();
|
||||
}
|
||||
return new BinaryManager({ verbose }, backend).ensureBinary();
|
||||
return new BinaryManager(
|
||||
{
|
||||
verbose,
|
||||
skipAutoUpdate: options.skipAutoUpdate ?? false,
|
||||
allowInstall: options.allowInstall ?? true,
|
||||
},
|
||||
backend
|
||||
).ensureBinary();
|
||||
}
|
||||
|
||||
/** Check if CLIProxyAPI binary is installed */
|
||||
|
||||
@@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void {
|
||||
if (verbose) console.error(`[cliproxy] ${message}`);
|
||||
}
|
||||
|
||||
function getBackendLabel(backend: CLIProxyBackend): string {
|
||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is above max stable (known unstable)
|
||||
*/
|
||||
@@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string
|
||||
/** Handle auto-update when binary exists */
|
||||
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
|
||||
const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND;
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
const backendLabel = getBackendLabel(backend);
|
||||
const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend);
|
||||
const currentVersion = updateResult.currentVersion;
|
||||
const latestVersion = updateResult.latestVersion;
|
||||
@@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
if (config.skipAutoUpdate) {
|
||||
log('Runtime bootstrap mode: skipping auto-update check', verbose);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAutoUpdate(config, verbose);
|
||||
} catch (error) {
|
||||
@@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
// Binary missing - download
|
||||
log('Binary not found, downloading...', verbose);
|
||||
|
||||
if (!config.allowInstall) {
|
||||
throw new Error(
|
||||
`${getBackendLabel(backend)} binary is not installed locally. ` +
|
||||
'Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.forceVersion) {
|
||||
try {
|
||||
const latestVersion = await fetchLatestVersion(verbose, backend);
|
||||
|
||||
@@ -329,7 +329,7 @@ export async function execClaudeWithCLIProxy(
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
|
||||
@@ -216,7 +216,10 @@ export async function ensureCliproxyService(
|
||||
// 1. Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
binaryPath = await ensureCLIProxyBinary(verbose, {
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
});
|
||||
log(`Binary ready: ${binaryPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface BinaryManagerConfig {
|
||||
verbose: boolean;
|
||||
/** Force specific version (skip auto-upgrade to latest) */
|
||||
forceVersion: boolean;
|
||||
/** Skip background update checks on runtime bootstrap paths */
|
||||
skipAutoUpdate: boolean;
|
||||
/** Allow downloading/installing the binary when it is missing */
|
||||
allowInstall: boolean;
|
||||
/** Backend variant (original vs plus) */
|
||||
backend?: CLIProxyBackend;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempHome = '';
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-binary-manager-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('installCliproxyVersion', () => {
|
||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||
@@ -42,4 +66,19 @@ describe('installCliproxyVersion', () => {
|
||||
expect(calls.deleteBinary).toBe(0);
|
||||
expect(calls.ensureBinary).toBe(1);
|
||||
});
|
||||
|
||||
it('fails fast when runtime startup forbids installing a missing binary', async () => {
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-runtime=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(
|
||||
binaryManager.ensureCLIProxyBinary(false, {
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const ensureBinaryCalls: Array<unknown> = [];
|
||||
|
||||
mock.module('../../../src/cliproxy/binary-manager', () => ({
|
||||
ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => {
|
||||
ensureBinaryCalls.push(options);
|
||||
throw new Error(
|
||||
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/config-generator', () => ({
|
||||
ensureConfigDir: () => undefined,
|
||||
generateConfig: () => '/tmp/cliproxy-config.yaml',
|
||||
regenerateConfig: () => '/tmp/cliproxy-config.yaml',
|
||||
configNeedsRegeneration: () => false,
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
getCliproxyWritablePath: () => '/tmp',
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/proxy-detector', () => ({
|
||||
detectRunningProxy: async () => ({ running: false, verified: false }),
|
||||
waitForProxyHealthy: async () => false,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/startup-lock', () => ({
|
||||
withStartupLock: async <T>(fn: () => Promise<T>) => await fn(),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/session-tracker', () => ({
|
||||
registerSession: () => undefined,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/stats-fetcher', () => ({
|
||||
isCliproxyRunning: async () => false,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({
|
||||
getTokenRefreshConfig: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({
|
||||
TokenRefreshWorker: class {
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
},
|
||||
}));
|
||||
|
||||
const { ensureCliproxyService } = await import(
|
||||
`../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}`
|
||||
);
|
||||
|
||||
describe('ensureCliproxyService', () => {
|
||||
it('fails fast without attempting a runtime install when the local binary is missing', async () => {
|
||||
ensureBinaryCalls.length = 0;
|
||||
|
||||
const result = await ensureCliproxyService(8317, false);
|
||||
|
||||
expect(result).toEqual({
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
error:
|
||||
'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.',
|
||||
});
|
||||
expect(ensureBinaryCalls).toEqual([
|
||||
{
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
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';
|
||||
@@ -14,6 +14,8 @@ describe('version-checker stale cache fallback', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
@@ -86,4 +88,50 @@ describe('version-checker stale cache fallback', () => {
|
||||
expect(result.latest).toBe('6.9.23-0');
|
||||
expect(result.fromCache).toBe(true);
|
||||
});
|
||||
|
||||
it('skips update lookups when runtime startup prefers the installed binary', async () => {
|
||||
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary');
|
||||
|
||||
let checkForUpdatesCalls = 0;
|
||||
|
||||
mock.module('../../../src/cliproxy/binary/version-checker', () => ({
|
||||
checkForUpdates: async () => {
|
||||
checkForUpdatesCalls += 1;
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion: '6.8.2-0',
|
||||
latestVersion: '6.8.2-0',
|
||||
fromCache: false,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
fetchLatestVersion: async () => {
|
||||
throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled');
|
||||
},
|
||||
isNewerVersion: () => false,
|
||||
isVersionFaulty: () => false,
|
||||
}));
|
||||
|
||||
const { ensureBinary } = await import(
|
||||
`../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}`
|
||||
);
|
||||
|
||||
const binaryPath = await ensureBinary({
|
||||
version: '6.8.2-0',
|
||||
releaseUrl: 'https://example.com/releases/download',
|
||||
binPath: plusBinDir,
|
||||
maxRetries: 1,
|
||||
verbose: false,
|
||||
forceVersion: false,
|
||||
skipAutoUpdate: true,
|
||||
allowInstall: true,
|
||||
backend: 'plus',
|
||||
});
|
||||
|
||||
expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus')));
|
||||
expect(checkForUpdatesCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,26 @@ describe('config command dashboard startup', () => {
|
||||
expect(errorLines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still opens the dashboard when CLIProxy is unavailable', async () => {
|
||||
await handleConfigCommand([], {
|
||||
...createTestDeps(),
|
||||
ensureCliproxyService: async () => ({
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port: 8317,
|
||||
error:
|
||||
'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(startServerCalls).toHaveLength(1);
|
||||
const rendered = logLines.join('\n');
|
||||
expect(rendered).toContain(
|
||||
'CLIProxy not available: Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited');
|
||||
});
|
||||
|
||||
it('fails cleanly when the server cannot bind the requested host', async () => {
|
||||
startServerError = new Error(
|
||||
'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
|
||||
|
||||
@@ -1,105 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { handleTokensCommand } from '../../../src/commands/tokens-command';
|
||||
import { getConfigYamlPath, loadUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { runWithScopedCcsHome, setGlobalConfigDir } from '../../../src/utils/config-manager';
|
||||
|
||||
async function loadTokensCommand() {
|
||||
return await import(
|
||||
`../../../src/commands/tokens-command?test=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
}
|
||||
async function withScopedTokensHome<T>(run: (tempHome: string) => Promise<T>): Promise<T> {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-'));
|
||||
setGlobalConfigDir(undefined);
|
||||
|
||||
async function loadCliproxyModule() {
|
||||
return await import(`../../../src/cliproxy?test=${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
|
||||
async function loadUnifiedConfigModule() {
|
||||
return await import(
|
||||
`../../../src/config/unified-config-loader?test=${Date.now()}-${Math.random()}`
|
||||
);
|
||||
try {
|
||||
return await runWithScopedCcsHome(tempHome, async () => await run(tempHome));
|
||||
} finally {
|
||||
setGlobalConfigDir(undefined);
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('tokens command auth rotation', () => {
|
||||
let tempHome = '';
|
||||
let logLines: string[] = [];
|
||||
let errorLines: string[] = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalNoColor: string | undefined;
|
||||
let originalConsoleLog: typeof console.log;
|
||||
let originalConsoleError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-'));
|
||||
logLines = [];
|
||||
errorLines = [];
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalNoColor = process.env.NO_COLOR;
|
||||
originalConsoleLog = console.log;
|
||||
originalConsoleError = console.error;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.NO_COLOR = '1';
|
||||
console.log = (...args: unknown[]) => {
|
||||
logLines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
errorLines.push(args.map(String).join(' '));
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalNoColor !== undefined) process.env.NO_COLOR = originalNoColor;
|
||||
else delete process.env.NO_COLOR;
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
console.error = originalConsoleError;
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies api-key and regenerated secret in a single invocation', async () => {
|
||||
const { handleTokensCommand } = await loadTokensCommand();
|
||||
const { getCliproxyConfigPath } = await loadCliproxyModule();
|
||||
const { loadUnifiedConfig } = await loadUnifiedConfigModule();
|
||||
await withScopedTokensHome(async () => {
|
||||
const exitCode = await handleTokensCommand([
|
||||
'--api-key',
|
||||
'ccs-custom-key-123',
|
||||
'--regenerate-secret',
|
||||
]);
|
||||
|
||||
const exitCode = await handleTokensCommand([
|
||||
'--api-key',
|
||||
'ccs-custom-key-123',
|
||||
'--regenerate-secret',
|
||||
]);
|
||||
const config = loadUnifiedConfig();
|
||||
const managementSecret = config?.cliproxy.auth?.management_secret;
|
||||
const configYamlPath = getConfigYamlPath();
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(errorLines).toHaveLength(0);
|
||||
expect(logLines.some((line) => line.includes('New management secret generated'))).toBe(true);
|
||||
expect(logLines.some((line) => line.includes('Global API key updated'))).toBe(true);
|
||||
expect(logLines.filter((line) => line.includes('CLIProxy config regenerated'))).toHaveLength(1);
|
||||
const diagnostics = {
|
||||
exitCode,
|
||||
configYamlPath,
|
||||
configExists: fs.existsSync(configYamlPath),
|
||||
apiKey: config?.cliproxy.auth?.api_key ?? null,
|
||||
managementSecretLength: (managementSecret ?? '').length,
|
||||
};
|
||||
|
||||
const config = loadUnifiedConfig();
|
||||
const managementSecret = config?.cliproxy.auth?.management_secret;
|
||||
expect(config?.cliproxy.auth?.api_key).toBe('ccs-custom-key-123');
|
||||
expect(typeof managementSecret).toBe('string');
|
||||
expect((managementSecret ?? '').length).toBeGreaterThan(20);
|
||||
|
||||
const cliproxyConfig = fs.readFileSync(getCliproxyConfigPath(), 'utf8');
|
||||
expect(cliproxyConfig).toContain('"ccs-custom-key-123"');
|
||||
if (
|
||||
exitCode !== 0 ||
|
||||
config?.cliproxy.auth?.api_key !== 'ccs-custom-key-123' ||
|
||||
typeof managementSecret !== 'string' ||
|
||||
(managementSecret ?? '').length <= 20
|
||||
) {
|
||||
throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects conflicting manual and generated secret flags', async () => {
|
||||
const { handleTokensCommand } = await loadTokensCommand();
|
||||
const { getConfigYamlPath } = await loadUnifiedConfigModule();
|
||||
await withScopedTokensHome(async () => {
|
||||
const exitCode = await handleTokensCommand([
|
||||
'--secret',
|
||||
'manual-secret',
|
||||
'--regenerate-secret',
|
||||
]);
|
||||
|
||||
const exitCode = await handleTokensCommand([
|
||||
'--secret',
|
||||
'manual-secret',
|
||||
'--regenerate-secret',
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(
|
||||
errorLines.some((line) => line.includes('Cannot combine --secret with --regenerate-secret'))
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(getConfigYamlPath())).toBe(false);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(fs.existsSync(getConfigYamlPath())).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user