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:
Tam Nhu Tran
2026-04-15 22:55:11 -04:00
parent 4608e2cbc3
commit 27f1416181
12 changed files with 283 additions and 96 deletions
+1 -1
View File
@@ -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}`);
+1 -1
View File
@@ -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');
+21 -2
View File
@@ -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 */
+17 -1
View File
@@ -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);
+1 -1
View File
@@ -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');
+4 -1
View File
@@ -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;
+4
View File
@@ -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);
});
});
});