Merge pull request #1028 from kaitranntt/kai/fix/969-private-network

fix: avoid network-bound local cliproxy startup
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-16 00:28:26 -04:00
committed by GitHub
14 changed files with 442 additions and 138 deletions
+1
View File
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites.
- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`.
+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,56 +1,111 @@
import { describe, it, expect, beforeEach, afterEach } 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 {
registerSession,
unregisterSession,
getProxyStatus,
} from '../../../src/cliproxy/session-tracker';
import { spawnSync } from 'child_process';
import { pathToFileURL } from 'url';
describe('session-tracker target metadata', () => {
let tmpDir: string;
let originalCcsHome: string | undefined;
const port = 28317;
const REPO_ROOT = path.resolve(import.meta.dir, '../../..');
const SESSION_TRACKER_URL = pathToFileURL(
path.join(REPO_ROOT, 'src/cliproxy/session-tracker.ts')
).href;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
});
function withScopedSessionTrackerHome<T>(run: (tempHome: string) => T): T {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
try {
return run(tempHome);
} finally {
fs.rmSync(tempHome, { recursive: true, force: true });
}
}
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
function runSessionTrackerScenario(
tempHome: string,
targets: string[]
): {
running: boolean;
target?: string;
sessionCount?: number;
} {
const script = `
import {
registerSession,
unregisterSession,
getProxyStatus,
} from ${JSON.stringify(SESSION_TRACKER_URL)};
const port = 28317;
const sessionIds = [];
for (const target of ${JSON.stringify(targets)}) {
sessionIds.push(registerSession(port, process.pid, undefined, undefined, target));
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns single target when all sessions share same target', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'droid');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('droid');
expect(status.sessionCount).toBe(2);
for (const sessionId of sessionIds) {
unregisterSession(sessionId, port);
}
unregisterSession(s1, port);
unregisterSession(s2, port);
console.log(JSON.stringify({
running: status.running,
target: status.target ?? null,
sessionCount: status.sessionCount ?? null,
}));
`;
const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`);
fs.writeFileSync(scriptPath, script, 'utf8');
const result = spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
env: {
...process.env,
CCS_HOME: tempHome,
CCS_DIR: '',
},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) {
throw new Error(
`child session-tracker scenario failed: ${JSON.stringify({
command: `${process.execPath} ${scriptPath}`,
status: result.status,
signal: result.signal,
error: result.error?.message ?? null,
stdout: result.stdout,
stderr: result.stderr,
})}`
);
}
const lines = result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return JSON.parse(lines.at(-1) || '{}') as {
running: boolean;
target?: string;
sessionCount?: number;
};
}
describe('session-tracker target metadata', () => {
it('returns single target when all sessions share same target', () => {
withScopedSessionTrackerHome((tempHome) => {
const status = runSessionTrackerScenario(tempHome, ['droid', 'droid']);
expect(status.running).toBe(true);
expect(status.target).toBe('droid');
expect(status.sessionCount).toBe(2);
});
});
it('returns mixed when active sessions use different targets', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'claude');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('mixed');
expect(status.sessionCount).toBe(2);
unregisterSession(s1, port);
unregisterSession(s2, port);
withScopedSessionTrackerHome((tempHome) => {
const status = runSessionTrackerScenario(tempHome, ['claude', 'droid']);
expect(status.running).toBe(true);
expect(status.target).toBe('mixed');
expect(status.sessionCount).toBe(2);
});
});
});
@@ -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,126 @@
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 { spawnSync } from 'child_process';
import { pathToFileURL } from 'url';
import { setGlobalConfigDir } from '../../../src/utils/config-manager';
async function loadTokensCommand() {
return await import(
`../../../src/commands/tokens-command?test=${Date.now()}-${Math.random()}`
);
const REPO_ROOT = path.resolve(import.meta.dir, '../../..');
const TOKENS_COMMAND_URL = pathToFileURL(
path.join(REPO_ROOT, 'src/commands/tokens-command.ts')
).href;
const UNIFIED_CONFIG_LOADER_URL = pathToFileURL(
path.join(REPO_ROOT, 'src/config/unified-config-loader.ts')
).href;
function withScopedTokensHome<T>(run: (tempHome: string) => T): T {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-'));
setGlobalConfigDir(undefined);
try {
return run(tempHome);
} finally {
setGlobalConfigDir(undefined);
fs.rmSync(tempHome, { recursive: true, force: true });
}
}
async function loadCliproxyModule() {
return await import(`../../../src/cliproxy?test=${Date.now()}-${Math.random()}`);
}
function runTokensCommandInChild(tempHome: string, args: string[]) {
const script = `
import { handleTokensCommand } from ${JSON.stringify(TOKENS_COMMAND_URL)};
import { loadUnifiedConfig } from ${JSON.stringify(UNIFIED_CONFIG_LOADER_URL)};
async function loadUnifiedConfigModule() {
return await import(
`../../../src/config/unified-config-loader?test=${Date.now()}-${Math.random()}`
);
const exitCode = await handleTokensCommand(${JSON.stringify(args)});
const config = loadUnifiedConfig();
const managementSecret = config?.cliproxy.auth?.management_secret ?? null;
console.log(JSON.stringify({
exitCode,
apiKey: config?.cliproxy.auth?.api_key ?? null,
managementSecretLength: typeof managementSecret === 'string' ? managementSecret.length : 0,
}));
`;
const scriptPath = path.join(tempHome, `tokens-child-${Date.now()}.mjs`);
fs.writeFileSync(scriptPath, script, 'utf8');
const result = spawnSync(process.execPath, [scriptPath], {
cwd: REPO_ROOT,
env: {
...process.env,
CCS_HOME: tempHome,
CCS_DIR: '',
NO_COLOR: '1',
},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) {
throw new Error(
`child tokens command failed: ${JSON.stringify({
command: `${process.execPath} ${scriptPath}`,
status: result.status,
signal: result.signal,
error: result.error?.message ?? null,
stdout: result.stdout,
stderr: result.stderr,
})}`
);
}
const lines = result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const payload = JSON.parse(lines.at(-1) || '{}') as {
exitCode: number;
apiKey: string | null;
managementSecretLength: number;
};
return { payload, stdout: result.stdout, stderr: result.stderr };
}
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;
it('applies api-key and regenerated secret in a single invocation', () => {
withScopedTokensHome((tempHome) => {
const { payload } = runTokensCommandInChild(tempHome, [
'--api-key',
'ccs-custom-key-123',
'--regenerate-secret',
]);
const configYamlPath = path.join(tempHome, '.ccs', 'config.yaml');
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;
const diagnostics = {
exitCode: payload.exitCode,
configYamlPath,
configExists: fs.existsSync(configYamlPath),
apiKey: payload.apiKey,
managementSecretLength: payload.managementSecretLength,
};
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(' '));
};
if (
payload.exitCode !== 0 ||
payload.apiKey !== 'ccs-custom-key-123' ||
payload.managementSecretLength <= 20
) {
throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`);
}
});
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
it('rejects conflicting manual and generated secret flags', () => {
withScopedTokensHome((tempHome) => {
const { payload } = runTokensCommandInChild(tempHome, [
'--secret',
'manual-secret',
'--regenerate-secret',
]);
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();
const exitCode = await handleTokensCommand([
'--api-key',
'ccs-custom-key-123',
'--regenerate-secret',
]);
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 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"');
});
it('rejects conflicting manual and generated secret flags', async () => {
const { handleTokensCommand } = await loadTokensCommand();
const { getConfigYamlPath } = await loadUnifiedConfigModule();
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(payload.exitCode).toBe(1);
expect(fs.existsSync(path.join(tempHome, '.ccs', 'config.yaml'))).toBe(false);
});
});
});