diff --git a/docker/README.md b/docker/README.md index b508b141..2759d35f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -131,6 +131,18 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dash On first startup, the integrated container generates per-install CLIProxy API and management secrets when the config is missing custom values. If you have already configured `cliproxy.auth.api_key` or `cliproxy.auth.management_secret`, Docker preserves those custom values. +If you upgraded from an older Docker deployment that used the historical `ccs-internal-managed` API key, CCS keeps that legacy key valid beside the new per-install key for 14 days by default. During the grace period, every `ccs docker up` prints the new key and expiry date to stderr. Override the window with `CCS_DOCKER_LEGACY_KEY_GRACE_DAYS`. + +```bash +ccs docker show-key # masked +ccs docker show-key --full # reveal the current key +ccs docker finalize-key-rotation +``` + +Run `finalize-key-rotation` after updating clients to remove the legacy key immediately. + +If a previous upgrade already replaced the old key before this grace logic was available, run once with `CCS_DOCKER_RESTORE_LEGACY_API_KEY=1` to explicitly restore the temporary legacy-key grace window. CCS does not infer this from random-looking custom keys. + ### Post-Deployment: Migrate Existing Auth Tokens If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume: diff --git a/docker/compose.yaml b/docker/compose.yaml index 65584042..9d27448a 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -21,6 +21,9 @@ services: # with the canonical ghcr.io/kaitranntt/ccs prefix. image: ${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest} restart: unless-stopped + environment: + CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}" + CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}" ports: - "3000:3000" - "8317:8317" diff --git a/docker/docker-compose.integrated.yml b/docker/docker-compose.integrated.yml index 5eaf3578..27b20e98 100644 --- a/docker/docker-compose.integrated.yml +++ b/docker/docker-compose.integrated.yml @@ -17,6 +17,8 @@ services: NODE_ENV: production NO_COLOR: "${NO_COLOR:-}" CCS_DEBUG: "${CCS_DEBUG:-}" + CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}" + CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}" volumes: - ccs_home:/root/.ccs - ccs_logs:/var/log/ccs diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 95e50bd0..ac8c19c6 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -15,6 +15,7 @@ import { getDeniedModelIdReasonForProvider } from '../ai-providers/model-id-norm import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation'; /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; @@ -623,8 +624,12 @@ function generateUnifiedConfigContent( const effectiveApiKey = getEffectiveApiKey(); const effectiveSecret = getEffectiveManagementSecret(); - // Build api-keys section with internal key + preserved user keys - const allApiKeys = [effectiveApiKey, ...userApiKeys]; + // Build api-keys section with internal key + preserved user keys. + // Docker upgrades may temporarily keep the historical default key as a + // compatibility grace key; the marker file controls when that is active. + const allApiKeys = Array.from( + new Set([effectiveApiKey, ...getActiveDockerLegacyApiKeys(), ...userApiKeys]) + ); const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); // Unified config with enhanced CLIProxyAPI features diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 3dbe0371..a4f81c32 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -281,7 +281,16 @@ export const CLIPROXY_SUBCOMMANDS = [ 'resume', ] as const; export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const; -export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const; +export const DOCKER_SUBCOMMANDS = [ + 'up', + 'down', + 'status', + 'update', + 'logs', + 'config', + 'show-key', + 'finalize-key-rotation', +] as const; export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const; export const TOKENS_FLAGS = [ '--show', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 78dfca09..1838db9c 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -220,6 +220,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands([], ['--port', '--proxy-port', '--host', '--help', '-h']); if (subcommand === 'logs') return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']); + if (subcommand === 'show-key') + return completeSubcommands([], ['--full', '--host', '--help', '-h']); return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker); case 'browser': if (!subcommand || subcommand.startsWith('-')) { diff --git a/src/commands/docker/finalize-key-rotation-subcommand.ts b/src/commands/docker/finalize-key-rotation-subcommand.ts new file mode 100644 index 00000000..eca538f5 --- /dev/null +++ b/src/commands/docker/finalize-key-rotation-subcommand.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import { DockerExecutor } from '../../docker'; +import { configExists, regenerateConfig } from '../../cliproxy/config/config-generator'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { finalizeDockerKeyRotation } from '../../docker/docker-key-rotation'; +import { box, fail, info, initUI, ok } from '../../utils/ui'; +import { collectUnexpectedDockerArgs, parseDockerTarget } from './options'; + +const KNOWN_FLAGS = ['--host', '--container-scope'] as const; + +function shouldWriteContainerVolume(args: string[]): boolean { + return args.includes('--container-scope') || fs.existsSync('/.dockerenv'); +} + +export async function handleFinalizeKeyRotation(args: string[]): Promise { + await initUI(); + + const parsed = parseDockerTarget(args, KNOWN_FLAGS); + const remainingArgs = parsed.remainingArgs.filter((arg) => arg !== '--container-scope'); + const errors = [ + ...parsed.errors, + ...collectUnexpectedDockerArgs(remainingArgs, { + knownFlags: [], + maxPositionals: 0, + }), + ]; + + if (errors.length > 0) { + console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 })); + process.exitCode = 1; + return; + } + + if (!shouldWriteContainerVolume(args)) { + try { + const output = new DockerExecutor().finalizeKeyRotation({ host: parsed.host }); + if (output.trim()) { + console.log(output.trim()); + } + } catch (error) { + console.error( + box(fail(error instanceof Error ? error.message : String(error)), { + title: 'Docker', + padding: 1, + }) + ); + process.exitCode = 1; + } + return; + } + + const status = finalizeDockerKeyRotation(); + if (configExists(CLIPROXY_DEFAULT_PORT)) { + regenerateConfig(CLIPROXY_DEFAULT_PORT); + } + + console.log(ok('Docker CLIProxy legacy API key grace period finalized.')); + console.log(info(`Current API key: ${status.maskedApiKey ?? '(not configured)'}`)); + console.log(info('Restart CLIProxy if it is already running to reload the regenerated config.')); +} diff --git a/src/commands/docker/help-subcommand.ts b/src/commands/docker/help-subcommand.ts index c7c3bdc3..fc716ea9 100644 --- a/src/commands/docker/help-subcommand.ts +++ b/src/commands/docker/help-subcommand.ts @@ -19,6 +19,8 @@ export async function showHelp(): Promise { ['update', 'Update CCS and CLIProxy inside the running container'], ['logs', 'Show or follow container log output'], ['config', 'Show bundled asset paths and deployment defaults'], + ['show-key', 'Show the Docker CLIProxy API key masked by default'], + ['finalize-key-rotation', 'End the legacy Docker API key grace period'], ], ], [ @@ -35,6 +37,7 @@ export async function showHelp(): Promise { ['up --proxy-port ', 'Publish CLIProxy on a custom host port'], ['logs --follow', 'Stream logs continuously'], ['logs --service ', 'Filter logs to ccs or cliproxy'], + ['show-key --full', 'Reveal the full Docker CLIProxy API key'], ], ], [ @@ -45,6 +48,9 @@ export async function showHelp(): Promise { ['ccs docker --host my-box status', 'Use the documented common-option ordering'], ['ccs docker up --host my-box', 'Stage assets to ~/.ccs/docker and deploy remotely'], ['ccs docker logs --follow --service ccs', 'Tail dashboard logs only'], + ['ccs docker show-key', 'Print the masked CLIProxy API key from the container'], + ['ccs docker show-key --full', 'Reveal the full CLIProxy API key'], + ['ccs docker finalize-key-rotation', 'Remove the legacy key immediately'], ['ccs docker update --host my-box', 'Update the running remote stack in place'], ], ], diff --git a/src/commands/docker/index.ts b/src/commands/docker/index.ts index 7ba3b882..764ef984 100644 --- a/src/commands/docker/index.ts +++ b/src/commands/docker/index.ts @@ -1,8 +1,10 @@ import { extractOption, hasAnyFlag } from '../arg-extractor'; import { handleConfig } from './config-subcommand'; import { handleDown } from './down-subcommand'; +import { handleFinalizeKeyRotation } from './finalize-key-rotation-subcommand'; import { showHelp } from './help-subcommand'; import { handleLogs } from './logs-subcommand'; +import { handleShowKey } from './show-key-subcommand'; import { handleStatus } from './status-subcommand'; import { handleUp } from './up-subcommand'; import { handleUpdate } from './update-subcommand'; @@ -44,6 +46,8 @@ export async function handleDockerCommand(args: string[]): Promise { update: handleUpdate, logs: handleLogs, config: handleConfig, + 'show-key': handleShowKey, + 'finalize-key-rotation': handleFinalizeKeyRotation, help: async () => showHelp(), }; diff --git a/src/commands/docker/show-key-subcommand.ts b/src/commands/docker/show-key-subcommand.ts new file mode 100644 index 00000000..03e5b3e8 --- /dev/null +++ b/src/commands/docker/show-key-subcommand.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import { DockerExecutor } from '../../docker'; +import { + getDockerKeyRotationStatus, + renderDockerKeyRotationBanner, +} from '../../docker/docker-key-rotation'; +import { box, fail, header, info, initUI } from '../../utils/ui'; +import { collectUnexpectedDockerArgs, parseDockerTarget } from './options'; + +const KNOWN_FLAGS = ['--host', '--full', '--container-scope', '--banner-only'] as const; + +function shouldReadContainerVolume(args: string[]): boolean { + return args.includes('--container-scope') || fs.existsSync('/.dockerenv'); +} + +export async function handleShowKey(args: string[]): Promise { + const bannerOnly = args.includes('--banner-only'); + if (!bannerOnly) { + await initUI(); + } + + const parsed = parseDockerTarget(args, KNOWN_FLAGS); + const remainingArgs = parsed.remainingArgs.filter( + (arg) => arg !== '--full' && arg !== '--container-scope' && arg !== '--banner-only' + ); + const errors = [ + ...parsed.errors, + ...collectUnexpectedDockerArgs(remainingArgs, { + knownFlags: [], + maxPositionals: 0, + }), + ]; + + if (errors.length > 0) { + console.error(box(fail(errors.join('\n')), { title: 'Docker', padding: 1 })); + process.exitCode = 1; + return; + } + + const full = args.includes('--full'); + if (!shouldReadContainerVolume(args)) { + try { + const output = new DockerExecutor().showKey({ host: parsed.host }, full); + if (output.trim()) { + console.log(output.trim()); + } + } catch (error) { + console.error( + box(fail(error instanceof Error ? error.message : String(error)), { + title: 'Docker', + padding: 1, + }) + ); + process.exitCode = 1; + } + return; + } + + const status = getDockerKeyRotationStatus(); + if (bannerOnly) { + const banner = renderDockerKeyRotationBanner(status); + if (banner) { + console.log(banner); + } + return; + } + + const displayKey = full ? status.apiKey : status.maskedApiKey; + console.log(header('Docker CLIProxy API Key')); + console.log(''); + console.log(info(`API key: ${displayKey ?? '(not configured)'}`)); + if (status.legacyGraceActive && status.legacyGrace) { + console.log(info(`Legacy key: ${status.legacyGrace.legacyKey}`)); + console.log(info(`Legacy key expires: ${status.legacyGrace.expiresAt}`)); + } else { + console.log(info('Legacy key grace: inactive')); + } + if (status.stateCorrupted) { + console.log(info(`State marker was unreadable and will be recreated: ${status.statePath}`)); + } +} diff --git a/src/commands/docker/up-subcommand.ts b/src/commands/docker/up-subcommand.ts index fa7c7bf3..616c0b67 100644 --- a/src/commands/docker/up-subcommand.ts +++ b/src/commands/docker/up-subcommand.ts @@ -34,6 +34,10 @@ export async function handleUp(args: string[]): Promise { ); try { await executor.up({ host: parsed.host, port, proxyPort }); + const rotationBanner = executor.getKeyRotationBanner({ host: parsed.host }); + if (rotationBanner) { + console.error(rotationBanner); + } console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`)); console.log(info(`Dashboard port: ${port}`)); console.log(info(`CLIProxy port: ${proxyPort}`)); diff --git a/src/docker/docker-bootstrap.ts b/src/docker/docker-bootstrap.ts index 41475393..11a078d5 100644 --- a/src/docker/docker-bootstrap.ts +++ b/src/docker/docker-bootstrap.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'crypto'; import { spawn } from 'child_process'; +import * as fs from 'fs'; import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from '../cliproxy/binary-manager'; import { configExists, @@ -13,34 +14,105 @@ import { import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getCliproxyConfigPath } from '../cliproxy/config/path-resolver'; import { registerSession, unregisterSession } from '../cliproxy/session-tracker'; -import { loadOrCreateUnifiedConfig, mutateConfig } from '../config/config-loader-facade'; +import { + getConfigYamlPath, + loadOrCreateUnifiedConfig, + mutateConfig, +} from '../config/config-loader-facade'; +import { + addLegacyKeyGrace, + createDockerBootstrapState, + DOCKER_LEGACY_API_KEY, + isDockerLegacyKeyGraceActive, + isLikelyDockerGeneratedApiKey, + readDockerBootstrapState, + shouldRestoreDockerLegacyApiKey, + writeDockerBootstrapState, +} from './docker-key-rotation'; function generateDockerSecret(): string { return randomBytes(32).toString('base64url'); } export function ensureDockerCliproxyAuth(): boolean { + const now = new Date(); + const hadUnifiedConfig = fs.existsSync(getConfigYamlPath()); + const hadCliproxyConfig = configExists(CLIPROXY_DEFAULT_PORT); + const cliproxyConfigPath = getCliproxyConfigPath(); + const existingCliproxyConfig = hadCliproxyConfig + ? fs.readFileSync(cliproxyConfigPath, 'utf8') + : ''; const config = loadOrCreateUnifiedConfig(); const auth = config.cliproxy.auth; const needsApiKey = !auth?.api_key || auth.api_key === CCS_INTERNAL_API_KEY; const needsManagementSecret = !auth?.management_secret || auth.management_secret === CCS_CONTROL_PANEL_SECRET; + const stateRead = readDockerBootstrapState(); + const existingState = stateRead.state; + const existingApiKey = auth?.api_key; - if (!needsApiKey && !needsManagementSecret) { - return false; + let replacementApiKey = existingApiKey; + let configChanged = false; + + if (needsApiKey || needsManagementSecret) { + mutateConfig((nextConfig) => { + nextConfig.cliproxy.auth ??= {}; + if (needsApiKey) { + replacementApiKey = generateDockerSecret(); + nextConfig.cliproxy.auth.api_key = replacementApiKey; + } + if (needsManagementSecret) { + nextConfig.cliproxy.auth.management_secret = generateDockerSecret(); + } + }); + configChanged = true; } - mutateConfig((nextConfig) => { - nextConfig.cliproxy.auth ??= {}; - if (needsApiKey) { - nextConfig.cliproxy.auth.api_key = generateDockerSecret(); - } - if (needsManagementSecret) { - nextConfig.cliproxy.auth.management_secret = generateDockerSecret(); - } - }); + if (!replacementApiKey) { + replacementApiKey = loadOrCreateUnifiedConfig().cliproxy.auth?.api_key; + } - return true; + const existingLegacyKeyInCliproxyConfig = existingCliproxyConfig.includes( + `"${DOCKER_LEGACY_API_KEY}"` + ); + const freshInstall = !hadUnifiedConfig && !hadCliproxyConfig; + const oldDefaultUpgrade = needsApiKey && !freshInstall; + const explicitLegacyRestore = shouldRestoreDockerLegacyApiKey(); + const legacyRestoreEligible = !existingState?.legacyKeyGrace; + const alreadyBrokenUpgrade = + explicitLegacyRestore && + legacyRestoreEligible && + hadCliproxyConfig && + isLikelyDockerGeneratedApiKey(replacementApiKey) && + !existingLegacyKeyInCliproxyConfig; + const corruptedRecoverableUpgrade = + explicitLegacyRestore && + stateRead.corrupted && + hadCliproxyConfig && + isLikelyDockerGeneratedApiKey(replacementApiKey); + + let nextState = existingState ?? createDockerBootstrapState(replacementApiKey, now); + + if (replacementApiKey && !nextState.apiKey) { + nextState = { ...nextState, apiKey: replacementApiKey }; + } + + if ( + replacementApiKey && + !isDockerLegacyKeyGraceActive(existingState, now) && + (oldDefaultUpgrade || alreadyBrokenUpgrade || corruptedRecoverableUpgrade) + ) { + nextState = addLegacyKeyGrace(nextState, replacementApiKey, now); + } + + if (!existingState || stateRead.corrupted || nextState !== existingState) { + writeDockerBootstrapState(nextState); + } + + const legacyShouldBePresent = isDockerLegacyKeyGraceActive(nextState, now); + const legacyPresenceChanged = existingLegacyKeyInCliproxyConfig !== legacyShouldBePresent; + + return configChanged || legacyPresenceChanged; } async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configPath: string }> { diff --git a/src/docker/docker-executor.ts b/src/docker/docker-executor.ts index 8ea2753b..477b951e 100644 --- a/src/docker/docker-executor.ts +++ b/src/docker/docker-executor.ts @@ -124,6 +124,11 @@ function runStreaming(command: string, args: string[]): Promise { }); } +function sleepSync(ms: number): void { + const buffer = new SharedArrayBuffer(4); + Atomics.wait(new Int32Array(buffer), 0, 0, ms); +} + let cachedLocalComposePrefix: string[] | undefined; function resolveLocalComposePrefix(): string[] { @@ -203,6 +208,8 @@ export class DockerExecutor { CCS_DASHBOARD_PORT: String(options.port), CCS_CLIPROXY_PORT: String(options.proxyPort), CCS_DOCKER_BIND_HOST: process.env.CCS_DOCKER_BIND_HOST || '127.0.0.1', + CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS || '', + CCS_DOCKER_RESTORE_LEGACY_API_KEY: process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY || '', }, REMOTE_DOCKER_BUILD_TIMEOUT_MS ), @@ -265,6 +272,58 @@ export class DockerExecutor { return result.stdout; } + showKey(options: DockerCommandTarget, full: boolean): string { + const args = [ + 'exec', + DOCKER_CONTAINER_NAME, + 'ccs', + 'docker', + 'show-key', + '--container-scope', + ...(full ? ['--full'] : []), + ]; + const result = this.runDocker(args, options); + this.ensureSuccess(result, 'Docker key display', options); + return result.stdout; + } + + finalizeKeyRotation(options: DockerCommandTarget): string { + const result = this.runDocker( + [ + 'exec', + DOCKER_CONTAINER_NAME, + 'ccs', + 'docker', + 'finalize-key-rotation', + '--container-scope', + ], + options + ); + this.ensureSuccess(result, 'Docker key rotation finalization', options); + return result.stdout; + } + + getKeyRotationBanner(options: DockerCommandTarget): string { + const args = [ + 'exec', + DOCKER_CONTAINER_NAME, + 'ccs', + 'docker', + 'show-key', + '--container-scope', + '--full', + '--banner-only', + ]; + for (let attempt = 0; attempt < 10; attempt += 1) { + const result = this.runDocker(args, options, LOCAL_DOCKER_SYNC_TIMEOUT_MS); + if (result.exitCode === 0) { + return result.stdout.trim(); + } + sleepSync(500); + } + return ''; + } + private stageRemoteAssets(host: string): void { this.ensureSuccess( this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }), diff --git a/src/docker/docker-key-rotation.ts b/src/docker/docker-key-rotation.ts new file mode 100644 index 00000000..30a7600a --- /dev/null +++ b/src/docker/docker-key-rotation.ts @@ -0,0 +1,202 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { loadOrCreateUnifiedConfig, getCcsDir } from '../config/config-loader-facade'; + +export const DOCKER_BOOTSTRAP_STATE_FILENAME = '.docker-bootstrap-state.json'; +export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed'; +export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14; +export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS'; +export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const STATE_VERSION = 1; + +export interface DockerLegacyKeyGrace { + legacyKey: string; + replacementKey: string; + startedAt: string; + expiresAt: string; + finalizedAt?: string; +} + +export interface DockerBootstrapState { + version: number; + apiKey?: string; + bootstrappedAt: string; + legacyKeyGrace?: DockerLegacyKeyGrace; +} + +export interface DockerBootstrapStateReadResult { + state: DockerBootstrapState | null; + corrupted: boolean; + path: string; +} + +export interface DockerKeyRotationStatus { + apiKey?: string; + maskedApiKey?: string; + statePath: string; + stateCorrupted: boolean; + legacyGraceActive: boolean; + legacyGrace?: DockerLegacyKeyGrace; +} + +export function getDockerBootstrapStatePath(): string { + return path.join(getCcsDir(), 'cliproxy', DOCKER_BOOTSTRAP_STATE_FILENAME); +} + +export function parseDockerLegacyKeyGraceDays(env = process.env): number { + const rawValue = env[DOCKER_LEGACY_KEY_GRACE_ENV]; + if (rawValue === undefined || rawValue.trim() === '') { + return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS; + } + + return Math.floor(parsed); +} + +export function isLikelyDockerGeneratedApiKey(value: string | undefined): boolean { + return Boolean(value && /^[A-Za-z0-9_-]{43}$/.test(value)); +} + +export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean { + return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1'; +} + +export function readDockerBootstrapState(): DockerBootstrapStateReadResult { + const statePath = getDockerBootstrapStatePath(); + if (!fs.existsSync(statePath)) { + return { state: null, corrupted: false, path: statePath }; + } + + try { + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as DockerBootstrapState; + if (!parsed || parsed.version !== STATE_VERSION || typeof parsed.bootstrappedAt !== 'string') { + return { state: null, corrupted: true, path: statePath }; + } + return { state: parsed, corrupted: false, path: statePath }; + } catch { + return { state: null, corrupted: true, path: statePath }; + } +} + +export function writeDockerBootstrapState(state: DockerBootstrapState): void { + const statePath = getDockerBootstrapStatePath(); + fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 }); + const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tempPath, statePath); +} + +export function createDockerBootstrapState( + apiKey: string | undefined, + now = new Date() +): DockerBootstrapState { + return { + version: STATE_VERSION, + apiKey, + bootstrappedAt: now.toISOString(), + }; +} + +export function addLegacyKeyGrace( + state: DockerBootstrapState, + replacementKey: string, + now = new Date(), + graceDays = parseDockerLegacyKeyGraceDays() +): DockerBootstrapState { + return { + ...state, + apiKey: replacementKey, + legacyKeyGrace: { + legacyKey: DOCKER_LEGACY_API_KEY, + replacementKey, + startedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + graceDays * DAY_MS).toISOString(), + }, + }; +} + +export function isDockerLegacyKeyGraceActive( + state: DockerBootstrapState | null, + now = new Date() +): boolean { + const grace = state?.legacyKeyGrace; + if (!grace || grace.finalizedAt) { + return false; + } + + const expiresAt = Date.parse(grace.expiresAt); + return Number.isFinite(expiresAt) && expiresAt > now.getTime(); +} + +export function getActiveDockerLegacyApiKeys(now = new Date()): string[] { + const { state } = readDockerBootstrapState(); + if (!isDockerLegacyKeyGraceActive(state, now)) { + return []; + } + + return state?.legacyKeyGrace?.legacyKey ? [state.legacyKeyGrace.legacyKey] : []; +} + +export function maskDockerApiKey(apiKey: string | undefined): string | undefined { + if (!apiKey) { + return undefined; + } + if (apiKey.length <= 8) { + return '****'; + } + return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`; +} + +export function getDockerKeyRotationStatus(now = new Date()): DockerKeyRotationStatus { + const config = loadOrCreateUnifiedConfig(); + const readResult = readDockerBootstrapState(); + const apiKey = config.cliproxy.auth?.api_key; + + return { + apiKey, + maskedApiKey: maskDockerApiKey(apiKey), + statePath: readResult.path, + stateCorrupted: readResult.corrupted, + legacyGraceActive: isDockerLegacyKeyGraceActive(readResult.state, now), + legacyGrace: readResult.state?.legacyKeyGrace, + }; +} + +export function renderDockerKeyRotationBanner(status = getDockerKeyRotationStatus()): string { + if (!status.legacyGraceActive || !status.legacyGrace) { + return ''; + } + + return [ + '[!] Docker CLIProxy API key rotation grace period is active.', + `[i] New CLIProxy API key: ${status.legacyGrace.replacementKey}`, + `[i] Legacy key ${status.legacyGrace.legacyKey} remains valid until ${status.legacyGrace.expiresAt}.`, + '[i] Update existing clients, then run `ccs docker finalize-key-rotation`.', + ].join('\n'); +} + +export function finalizeDockerKeyRotation(now = new Date()): DockerKeyRotationStatus { + const readResult = readDockerBootstrapState(); + const state = + readResult.state ?? + createDockerBootstrapState(loadOrCreateUnifiedConfig().cliproxy.auth?.api_key, now); + + writeDockerBootstrapState({ + ...state, + legacyKeyGrace: state.legacyKeyGrace + ? { + ...state.legacyKeyGrace, + finalizedAt: now.toISOString(), + expiresAt: now.toISOString(), + } + : undefined, + }); + + return getDockerKeyRotationStatus(now); +} diff --git a/src/management/doctor.ts b/src/management/doctor.ts index dcd9c0b8..e7cace57 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -16,6 +16,7 @@ import { runImageAnalysisCheck, } from './checks'; import { runAutoRepair } from './repair'; +import { getDockerKeyRotationStatus } from '../docker/docker-key-rotation'; /** * Doctor Class - Orchestrates health checks @@ -55,6 +56,7 @@ class Doctor { // Group 3: Configuration console.log(header('CONFIGURATION')); runConfigChecks(this.results); + this.runDockerKeyRotationCheck(); console.log(''); // Group 4: Profiles & Delegation @@ -158,6 +160,48 @@ class Doctor { console.log(''); } + private runDockerKeyRotationCheck(): void { + const status = getDockerKeyRotationStatus(); + if (status.legacyGraceActive && status.legacyGrace) { + this.results.addCheck( + 'Docker Key Rotation', + 'warning', + `Legacy API key remains valid until ${status.legacyGrace.expiresAt}`, + 'Run `ccs docker show-key --full`, update clients, then run `ccs docker finalize-key-rotation`.', + { + status: 'WARN', + info: `legacy key grace active until ${status.legacyGrace.expiresAt}`, + } + ); + return; + } + + if (status.stateCorrupted) { + this.results.addCheck( + 'Docker Key Rotation', + 'warning', + 'Docker bootstrap state marker is unreadable', + 'Run `ccs docker up` to recreate the marker.', + { + status: 'WARN', + info: 'state marker unreadable', + } + ); + return; + } + + this.results.addCheck( + 'Docker Key Rotation', + 'success', + 'No active legacy API key grace', + undefined, + { + status: 'OK', + info: 'no active grace', + } + ); + } + /** * Generate JSON report */ diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 4667dc76..456e724f 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -154,6 +154,10 @@ describe('completion backend', () => { test('includes live doctor and cliproxy flags from the shared catalog', () => { expect(suggestionValues(['doctor'])).toEqual(expect.arrayContaining(['--fix', '-f'])); expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend'])); + expect(suggestionValues(['docker'])).toEqual( + expect.arrayContaining(['show-key', 'finalize-key-rotation']) + ); + expect(suggestionValues(['docker', 'show-key'])).toEqual(expect.arrayContaining(['--full'])); }); test('suggests browser subcommands and fix/setup flags', () => { diff --git a/tests/unit/commands/docker-command.test.ts b/tests/unit/commands/docker-command.test.ts index 0cc55f38..0d5a42bc 100644 --- a/tests/unit/commands/docker-command.test.ts +++ b/tests/unit/commands/docker-command.test.ts @@ -49,6 +49,18 @@ beforeEach(() => { calls.push(`config:${args.join(' ')}`); }, })); + + mock.module('../../../src/commands/docker/show-key-subcommand', () => ({ + handleShowKey: async (args: string[]) => { + calls.push(`show-key:${args.join(' ')}`); + }, + })); + + mock.module('../../../src/commands/docker/finalize-key-rotation-subcommand', () => ({ + handleFinalizeKeyRotation: async (args: string[]) => { + calls.push(`finalize-key-rotation:${args.join(' ')}`); + }, + })); }); afterEach(() => { @@ -104,4 +116,13 @@ describe('docker command', () => { expect(calls).toEqual(['help']); expect(process.exitCode).toBe(1); }); + + it('routes Docker key rotation subcommands', async () => { + const handleDockerCommand = await loadHandleDockerCommand(); + + await handleDockerCommand(['show-key', '--full']); + await handleDockerCommand(['finalize-key-rotation']); + + expect(calls).toEqual(['show-key:--full', 'finalize-key-rotation:']); + }); }); diff --git a/tests/unit/commands/docker-key-rotation-subcommands.test.ts b/tests/unit/commands/docker-key-rotation-subcommands.test.ts new file mode 100644 index 00000000..e7ec4b6e --- /dev/null +++ b/tests/unit/commands/docker-key-rotation-subcommands.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { + renderCapturedLines, + useDockerSubcommandConsoleCapture, +} from './docker-subcommand-test-helpers'; +import { ensureDockerCliproxyAuth } from '../../../src/docker/docker-bootstrap'; +import { mutateConfig } from '../../../src/config/config-loader-facade'; +import { + CCS_INTERNAL_API_KEY, + regenerateConfig, +} from '../../../src/cliproxy/config/config-generator'; +import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { readDockerBootstrapState } from '../../../src/docker/docker-key-rotation'; + +const capture = useDockerSubcommandConsoleCapture(); +const originalCcsHome = process.env.CCS_HOME; +const tempDirs: string[] = []; + +function useTempCcsHome(): void { + const dir = mkdtempSync(join(tmpdir(), 'ccs-docker-key-command-')); + tempDirs.push(dir); + process.env.CCS_HOME = dir; +} + +async function loadHandleShowKey() { + const mod = await import( + `../../../src/commands/docker/show-key-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleShowKey; +} + +async function loadHandleFinalizeKeyRotation() { + const mod = await import( + `../../../src/commands/docker/finalize-key-rotation-subcommand?test=${Date.now()}-${Math.random()}` + ); + return mod.handleFinalizeKeyRotation; +} + +afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('docker key rotation subcommands', () => { + it('shows the Docker API key masked by default and full with --full', async () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + const replacementKey = readDockerBootstrapState().state?.legacyKeyGrace?.replacementKey; + + const handleShowKey = await loadHandleShowKey(); + await handleShowKey(['--container-scope']); + const maskedOutput = renderCapturedLines(capture.logLines); + capture.logLines.length = 0; + await handleShowKey(['--container-scope', '--full']); + const fullOutput = renderCapturedLines(capture.logLines); + + expect(replacementKey).toBeTruthy(); + expect(maskedOutput).toContain('API key:'); + expect(maskedOutput).not.toContain(replacementKey ?? ''); + expect(fullOutput).toContain(`API key: ${replacementKey}`); + expect(fullOutput).toContain('Legacy key expires:'); + }); + + it('finalizes active legacy-key grace immediately', async () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + + const handleFinalize = await loadHandleFinalizeKeyRotation(); + await handleFinalize(['--container-scope']); + + const state = readDockerBootstrapState().state; + const content = readFileSync(configPath, 'utf8'); + expect(state?.legacyKeyGrace?.finalizedAt).toBeTruthy(); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(renderCapturedLines(capture.logLines)).toContain( + 'Docker CLIProxy legacy API key grace period finalized.' + ); + }); +}); diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts index 9aace1f1..39d491e8 100644 --- a/tests/unit/commands/docker-up-subcommand.test.ts +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -20,6 +20,7 @@ describe('docker up subcommand', () => { '../../../src/docker' )) as typeof import('../../../src/docker'); const originalUp = dockerModule.DockerExecutor.prototype.up; + const originalGetKeyRotationBanner = dockerModule.DockerExecutor.prototype.getKeyRotationBanner; dockerModule.DockerExecutor.prototype.up = function (options: { host?: string; port: number; @@ -27,6 +28,13 @@ describe('docker up subcommand', () => { }) { calls.push(options); }; + dockerModule.DockerExecutor.prototype.getKeyRotationBanner = function () { + return [ + '[!] Docker CLIProxy API key rotation grace period is active.', + '[i] New CLIProxy API key: new-secret', + '[i] Legacy key ccs-internal-managed remains valid until 2026-06-05T00:00:00.000Z.', + ].join('\n'); + }; try { const handleUp = await loadHandleUp(); @@ -40,10 +48,14 @@ describe('docker up subcommand', () => { expect(rendered).toContain('CLIProxy port: 9317'); expect(rendered).toContain('Full remote management requires dashboard auth'); expect(rendered).toContain('Without it, remote access stays read-only.'); - expect(capture.errorLines).toEqual([]); + expect(renderCapturedLines(capture.errorLines)).toContain( + 'Docker CLIProxy API key rotation grace period is active' + ); + expect(renderCapturedLines(capture.errorLines)).toContain('New CLIProxy API key: new-secret'); expect(process.exitCode).toBe(0); } finally { dockerModule.DockerExecutor.prototype.up = originalUp; + dockerModule.DockerExecutor.prototype.getKeyRotationBanner = originalGetKeyRotationBanner; } }); diff --git a/tests/unit/docker/docker-assets-bundled.test.ts b/tests/unit/docker/docker-assets-bundled.test.ts index 8ca0cf3b..f93af76c 100644 --- a/tests/unit/docker/docker-assets-bundled.test.ts +++ b/tests/unit/docker/docker-assets-bundled.test.ts @@ -31,4 +31,26 @@ describe('docker bundled assets', () => { expect(compose).not.toContain('"${CCS_DASHBOARD_PORT:-3000}:3000"'); expect(compose).not.toContain('"${CCS_CLIPROXY_PORT:-8317}:8317"'); }); + + it('passes Docker key rotation controls into the integrated container', () => { + const compose = readFileSync(assets.composeFile, 'utf8'); + + expect(compose).toContain( + 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"' + ); + expect(compose).toContain( + 'CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"' + ); + }); + + it('passes Docker key rotation controls into the public compose file', () => { + const compose = readFileSync('docker/compose.yaml', 'utf8'); + + expect(compose).toContain( + 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS: "${CCS_DOCKER_LEGACY_KEY_GRACE_DAYS:-}"' + ); + expect(compose).toContain( + 'CCS_DOCKER_RESTORE_LEGACY_API_KEY: "${CCS_DOCKER_RESTORE_LEGACY_API_KEY:-}"' + ); + }); }); diff --git a/tests/unit/docker/docker-bootstrap.test.ts b/tests/unit/docker/docker-bootstrap.test.ts index 369ba4b7..cad19b1b 100644 --- a/tests/unit/docker/docker-bootstrap.test.ts +++ b/tests/unit/docker/docker-bootstrap.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, describe, expect, it } from 'bun:test'; @@ -7,9 +7,22 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../../src/config/con import { CCS_CONTROL_PANEL_SECRET, CCS_INTERNAL_API_KEY, + generateConfig, + regenerateConfig, } from '../../../src/cliproxy/config/config-generator'; +import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { getConfigPathForPort } from '../../../src/cliproxy/config/path-resolver'; +import { + DOCKER_BOOTSTRAP_STATE_FILENAME, + DOCKER_LEGACY_API_KEY, + getDockerBootstrapStatePath, + parseDockerLegacyKeyGraceDays, + readDockerBootstrapState, +} from '../../../src/docker/docker-key-rotation'; const originalCcsHome = process.env.CCS_HOME; +const originalGraceDays = process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS; +const originalRestoreLegacyKey = process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY; const tempDirs: string[] = []; function useTempCcsHome(): string { @@ -25,6 +38,16 @@ afterEach(() => { } else { process.env.CCS_HOME = originalCcsHome; } + if (originalGraceDays === undefined) { + delete process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS; + } else { + process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = originalGraceDays; + } + if (originalRestoreLegacyKey === undefined) { + delete process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY; + } else { + process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = originalRestoreLegacyKey; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -44,6 +67,17 @@ describe('docker bootstrap auth', () => { expect(config.cliproxy.auth?.api_key).not.toBe(CCS_INTERNAL_API_KEY); expect(config.cliproxy.auth?.management_secret).not.toBe(CCS_CONTROL_PANEL_SECRET); expect(config.cliproxy.auth?.api_key).not.toBe(config.cliproxy.auth?.management_secret); + expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined(); + }); + + it('keeps fresh generated CLIProxy config free of the legacy key', () => { + useTempCcsHome(); + + ensureDockerCliproxyAuth(); + const configPath = generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(content).not.toContain(DOCKER_LEGACY_API_KEY); }); it('preserves custom CLIProxy auth values for Docker deployments', () => { @@ -61,5 +95,158 @@ describe('docker bootstrap auth', () => { expect(changed).toBe(false); expect(config.cliproxy.auth?.api_key).toBe('custom-api-key'); expect(config.cliproxy.auth?.management_secret).toBe('custom-management-secret'); + expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined(); + }); + + it('preserves the legacy key during the default upgrade grace window', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { + api_key: CCS_INTERNAL_API_KEY, + management_secret: CCS_CONTROL_PANEL_SECRET, + }; + }); + + const changed = ensureDockerCliproxyAuth(); + const config = loadOrCreateUnifiedConfig(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + const state = readDockerBootstrapState().state; + + expect(changed).toBe(true); + expect(config.cliproxy.auth?.api_key).not.toBe(CCS_INTERNAL_API_KEY); + expect(state?.legacyKeyGrace?.legacyKey).toBe(CCS_INTERNAL_API_KEY); + expect(state?.legacyKeyGrace?.replacementKey).toBe(config.cliproxy.auth?.api_key); + expect(content).toContain(`"${config.cliproxy.auth?.api_key}"`); + expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + }); + + it('honors CCS_DOCKER_LEGACY_KEY_GRACE_DAYS for upgrade expiry', () => { + useTempCcsHome(); + process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = '1'; + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + + ensureDockerCliproxyAuth(); + const state = readDockerBootstrapState().state; + const startedAt = Date.parse(state?.legacyKeyGrace?.startedAt ?? ''); + const expiresAt = Date.parse(state?.legacyKeyGrace?.expiresAt ?? ''); + + expect(parseDockerLegacyKeyGraceDays()).toBe(1); + expect(expiresAt - startedAt).toBe(24 * 60 * 60 * 1000); + }); + + it('drops the legacy key when the grace window has already expired', () => { + useTempCcsHome(); + process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS = '0'; + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + + ensureDockerCliproxyAuth(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(content).not.toContain(CCS_INTERNAL_API_KEY); + }); + + it('does not auto-add the legacy key for custom generated-looking API keys', () => { + useTempCcsHome(); + const generatedLookingCustomKey = 'A'.repeat(43); + mutateConfig((config) => { + config.cliproxy.auth = { + api_key: generatedLookingCustomKey, + management_secret: 'custom-management-secret', + }; + }); + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + const changed = ensureDockerCliproxyAuth(); + const content = readFileSync(getConfigPathForPort(CLIPROXY_DEFAULT_PORT), 'utf8'); + + expect(changed).toBe(false); + expect(content).toContain(`"${generatedLookingCustomKey}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); + }); + + it('restores the legacy key for already-broken random-key installs when explicitly requested', () => { + useTempCcsHome(); + process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1'; + const generatedKey = 'A'.repeat(43); + mutateConfig((config) => { + config.cliproxy.auth = { + api_key: generatedKey, + management_secret: 'custom-management-secret', + }; + }); + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + const changed = ensureDockerCliproxyAuth(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(changed).toBe(true); + expect(content).toContain(`"${generatedKey}"`); + expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + }); + + it('restores the legacy key after an earlier run wrote a no-grace marker', () => { + useTempCcsHome(); + const generatedKey = 'C'.repeat(43); + mutateConfig((config) => { + config.cliproxy.auth = { + api_key: generatedKey, + management_secret: 'custom-management-secret', + }; + }); + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + expect(ensureDockerCliproxyAuth()).toBe(false); + expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined(); + + process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1'; + const changed = ensureDockerCliproxyAuth(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(changed).toBe(true); + expect(content).toContain(`"${generatedKey}"`); + expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + }); + + it('recovers safely from a corrupted marker file during broken-install recovery', () => { + useTempCcsHome(); + process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = '1'; + const generatedKey = 'B'.repeat(43); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: generatedKey }; + }); + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + mkdirSync(join(getDockerBootstrapStatePath(), '..'), { recursive: true }); + writeFileSync(getDockerBootstrapStatePath(), '{not-json'); + + ensureDockerCliproxyAuth(); + const configPath = regenerateConfig(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(getDockerBootstrapStatePath()).toContain(DOCKER_BOOTSTRAP_STATE_FILENAME); + expect(readDockerBootstrapState().corrupted).toBe(false); + expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + }); + + it('does not treat human custom keys as broken Docker-generated keys', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: 'custom-human-key' }; + }); + generateConfig('gemini', CLIPROXY_DEFAULT_PORT); + + const changed = ensureDockerCliproxyAuth(); + const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT); + const content = readFileSync(configPath, 'utf8'); + + expect(changed).toBe(true); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); }); diff --git a/tests/unit/docker/docker-executor.test.ts b/tests/unit/docker/docker-executor.test.ts index 5cab02e3..ddb5b396 100644 --- a/tests/unit/docker/docker-executor.test.ts +++ b/tests/unit/docker/docker-executor.test.ts @@ -54,6 +54,8 @@ describe('docker executor', () => { expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000'); expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317'); expect(calls[0].options?.env?.CCS_DOCKER_BIND_HOST).toBe('127.0.0.1'); + expect(calls[0].options?.env?.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS).toBe(''); + expect(calls[0].options?.env?.CCS_DOCKER_RESTORE_LEGACY_API_KEY).toBe(''); expect(calls[0].options?.timeoutMs).toBe(300_000); }); @@ -91,6 +93,8 @@ describe('docker executor', () => { expect(calls[2].args[1]).toContain("export CCS_DASHBOARD_PORT='3000'"); expect(calls[2].args[1]).toContain("export CCS_CLIPROXY_PORT='8317'"); expect(calls[2].args[1]).toContain("export CCS_DOCKER_BIND_HOST='127.0.0.1'"); + expect(calls[2].args[1]).toContain("export CCS_DOCKER_LEGACY_KEY_GRACE_DAYS=''"); + expect(calls[2].args[1]).toContain("export CCS_DOCKER_RESTORE_LEGACY_API_KEY=''"); expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1'); expect(calls[2].options?.timeoutMs).toBe(300_000); });