mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
Merge pull request #1028 from kaitranntt/kai/fix/969-private-network
fix: avoid network-bound local cliproxy startup
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user