From 27f1416181904cc256a8694e6e15189ca673333c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 22:55:11 -0400 Subject: [PATCH 1/4] 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 --- src/cliproxy/auth/kiro-import.ts | 2 +- src/cliproxy/auth/oauth-handler.ts | 2 +- src/cliproxy/binary-manager.ts | 23 ++- src/cliproxy/binary/lifecycle.ts | 18 ++- src/cliproxy/executor/index.ts | 2 +- src/cliproxy/service-manager.ts | 5 +- src/cliproxy/types.ts | 4 + .../cliproxy/binary-manager-install.test.ts | 41 +++++- .../cliproxy/service-manager-startup.test.ts | 78 ++++++++++ .../version-checker-stale-cache.test.ts | 50 ++++++- tests/unit/commands/config-command.test.ts | 20 +++ .../tokens-command-auth-rotation.test.ts | 134 ++++++------------ 12 files changed, 283 insertions(+), 96 deletions(-) create mode 100644 tests/unit/cliproxy/service-manager-startup.test.ts diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts index cc507110..9e83facd 100644 --- a/src/cliproxy/auth/kiro-import.ts +++ b/src/cliproxy/auth/kiro-import.ts @@ -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}`); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 38df474f..7b1482fa 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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'); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 194faac7..452bd162 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -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 { +export async function ensureCLIProxyBinary( + verbose = false, + options: EnsureCLIProxyBinaryOptions = {} +): Promise { 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 { 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 */ diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 48700ce5..24e64d5c 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -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 { 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 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 // 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); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 5c2c25e4..4ad89052 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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'); diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 0ef4d2e7..b97add9f 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -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; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 7a55922a..ea723480 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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; } diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 533f38bc..92140f1d 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -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.' + ); + }); }); diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts new file mode 100644 index 00000000..5b85f9fc --- /dev/null +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, mock } from 'bun:test'; + +const ensureBinaryCalls: Array = []; + +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 (fn: () => Promise) => 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, + }, + ]); + }); +}); diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index 27c03871..c8829faa 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -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); + }); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 98d2320e..2fd3fbcf 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -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' diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 4c3e95aa..d2c86e8b 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -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(run: (tempHome: string) => Promise): Promise { + 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); + }); }); }); From 3b17bc934a01b93f016e85de4c4a7a162863dcf7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:08:25 -0400 Subject: [PATCH 2/4] docs(roadmap): note private-network startup fix --- docs/project-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index b8371475..2cdfda56 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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`. From 2ba3a0ab02fc4909681a36cc265e5ff2d1680426 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:08:46 -0400 Subject: [PATCH 3/4] test(ci): stabilize runner-sensitive isolated tests - isolate tokens and session-tracker tests in child processes - make child scripts resolve repo modules from the test location - avoid machine-specific paths in the repo --- .../cliproxy/session-tracker-target.test.ts | 137 ++++++++++++------ .../tokens-command-auth-rotation.test.ts | 109 +++++++++++--- 2 files changed, 181 insertions(+), 65 deletions(-) diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts index 2a8d23ad..be4257e1 100644 --- a/tests/unit/cliproxy/session-tracker-target.test.ts +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -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(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('/bin/bash', ['-lc', `bun ${JSON.stringify(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: `bun ${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); + }); }); }); diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index d2c86e8b..1ef6c716 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -2,64 +2,125 @@ 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'; +import { spawnSync } from 'child_process'; +import { pathToFileURL } from 'url'; +import { setGlobalConfigDir } from '../../../src/utils/config-manager'; -async function withScopedTokensHome(run: (tempHome: string) => Promise): Promise { +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(run: (tempHome: string) => T): T { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); setGlobalConfigDir(undefined); try { - return await runWithScopedCcsHome(tempHome, async () => await run(tempHome)); + return run(tempHome); } finally { setGlobalConfigDir(undefined); fs.rmSync(tempHome, { recursive: true, force: true }); } } +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)}; + + 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('/bin/bash', ['-lc', `bun ${JSON.stringify(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: `bun ${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', () => { - it('applies api-key and regenerated secret in a single invocation', async () => { - await withScopedTokensHome(async () => { - const exitCode = await handleTokensCommand([ + 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 config = loadUnifiedConfig(); - const managementSecret = config?.cliproxy.auth?.management_secret; - const configYamlPath = getConfigYamlPath(); + const configYamlPath = path.join(tempHome, '.ccs', 'config.yaml'); const diagnostics = { - exitCode, + exitCode: payload.exitCode, configYamlPath, configExists: fs.existsSync(configYamlPath), - apiKey: config?.cliproxy.auth?.api_key ?? null, - managementSecretLength: (managementSecret ?? '').length, + apiKey: payload.apiKey, + managementSecretLength: payload.managementSecretLength, }; if ( - exitCode !== 0 || - config?.cliproxy.auth?.api_key !== 'ccs-custom-key-123' || - typeof managementSecret !== 'string' || - (managementSecret ?? '').length <= 20 + payload.exitCode !== 0 || + payload.apiKey !== 'ccs-custom-key-123' || + payload.managementSecretLength <= 20 ) { throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`); } }); }); - it('rejects conflicting manual and generated secret flags', async () => { - await withScopedTokensHome(async () => { - const exitCode = await handleTokensCommand([ + it('rejects conflicting manual and generated secret flags', () => { + withScopedTokensHome((tempHome) => { + const { payload } = runTokensCommandInChild(tempHome, [ '--secret', 'manual-secret', '--regenerate-secret', ]); - expect(exitCode).toBe(1); - expect(fs.existsSync(getConfigYamlPath())).toBe(false); + expect(payload.exitCode).toBe(1); + expect(fs.existsSync(path.join(tempHome, '.ccs', 'config.yaml'))).toBe(false); }); }); }); From 2e2ba1c09b81b5e79b7ec65bbd0979d5ce1a1204 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:20:01 -0400 Subject: [PATCH 4/4] test(ci): use runtime-relative isolated child scripts - derive repo imports from each test file via file URLs - avoid hardcoded paths in the repo - launch child scripts with the current runtime for consistent local and runner behavior --- tests/unit/cliproxy/session-tracker-target.test.ts | 4 ++-- tests/unit/commands/tokens-command-auth-rotation.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts index be4257e1..762e1f04 100644 --- a/tests/unit/cliproxy/session-tracker-target.test.ts +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -55,7 +55,7 @@ function runSessionTrackerScenario( const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`); fs.writeFileSync(scriptPath, script, 'utf8'); - const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + const result = spawnSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, env: { ...process.env, @@ -69,7 +69,7 @@ function runSessionTrackerScenario( if (result.status !== 0) { throw new Error( `child session-tracker scenario failed: ${JSON.stringify({ - command: `bun ${scriptPath}`, + command: `${process.execPath} ${scriptPath}`, status: result.status, signal: result.signal, error: result.error?.message ?? null, diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 1ef6c716..9692148a 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -45,7 +45,7 @@ function runTokensCommandInChild(tempHome: string, args: string[]) { const scriptPath = path.join(tempHome, `tokens-child-${Date.now()}.mjs`); fs.writeFileSync(scriptPath, script, 'utf8'); - const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + const result = spawnSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, env: { ...process.env, @@ -60,7 +60,7 @@ function runTokensCommandInChild(tempHome: string, args: string[]) { if (result.status !== 0) { throw new Error( `child tokens command failed: ${JSON.stringify({ - command: `bun ${scriptPath}`, + command: `${process.execPath} ${scriptPath}`, status: result.status, signal: result.signal, error: result.error?.message ?? null,