From 8e4def4713a9932c7b741e9bf791ee9904d4d695 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 21:13:20 -0400 Subject: [PATCH 1/7] fix(cliproxy): keep proxy available during updates --- docker/host/ccs-cliproxy-reconcile.sh | 79 +++++++++ docker/host/ccs-cliproxy-safe-update.sh | 125 +++++++++++++++ .../systemd/ccs-cliproxy-reconcile.service | 9 ++ .../host/systemd/ccs-cliproxy-reconcile.timer | 11 ++ .../host/systemd/ccs-cliproxy-update.service | 9 ++ docker/host/systemd/ccs-cliproxy-update.timer | 10 ++ src/cliproxy/binary-manager.ts | 9 -- .../__tests__/binary-installer-atomic.test.ts | 151 ++++++++++++++++++ .../__tests__/binary-manager-install.test.ts | 32 ++++ src/cliproxy/binary/installer.ts | 86 +++++----- src/cliproxy/binary/lifecycle.ts | 4 +- .../cliproxy-host-continuity-assets.test.ts | 50 ++++++ 12 files changed, 521 insertions(+), 54 deletions(-) create mode 100755 docker/host/ccs-cliproxy-reconcile.sh create mode 100755 docker/host/ccs-cliproxy-safe-update.sh create mode 100644 docker/host/systemd/ccs-cliproxy-reconcile.service create mode 100644 docker/host/systemd/ccs-cliproxy-reconcile.timer create mode 100644 docker/host/systemd/ccs-cliproxy-update.service create mode 100644 docker/host/systemd/ccs-cliproxy-update.timer create mode 100644 src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts create mode 100644 tests/unit/docker/cliproxy-host-continuity-assets.test.ts diff --git a/docker/host/ccs-cliproxy-reconcile.sh b/docker/host/ccs-cliproxy-reconcile.sh new file mode 100755 index 00000000..0363f449 --- /dev/null +++ b/docker/host/ccs-cliproxy-reconcile.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" +compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker}" +compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.integrated.yml}" +lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" +log_file="${CCS_CLIPROXY_RECONCILE_LOG:-/var/log/ccs-cliproxy-reconcile.log}" + +log() { + printf '[%s] %s\n' "$(date -Is)" "$*" | tee -a "$log_file" +} + +exec 9>"$lock_file" +if ! flock -n 9; then + exit 0 +fi + +probe() { + docker exec "$container_name" sh -c \ + 'curl -fsS --max-time 2 http://127.0.0.1:3000/ >/dev/null && curl -fsS --max-time 2 http://127.0.0.1:8317/ >/dev/null' \ + >/dev/null 2>&1 +} + +wait_for_health() { + attempts=0 + while [ "$attempts" -lt 30 ]; do + if probe; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 2 + done + return 1 +} + +compose_up() { + cd "$compose_dir" + docker compose -f "$compose_file" up -d --no-build || \ + docker compose -f "$compose_file" up -d --build +} + +if ! docker inspect "$container_name" >/dev/null 2>&1; then + log "Container $container_name is missing; recreating from $compose_file" + compose_up + wait_for_health + log "Container $container_name was recreated and passed both health probes" + exit 0 +fi + +if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != 'true' ]; then + log "Container $container_name is stopped; restoring the Compose service" + compose_up + wait_for_health + log "Container $container_name is running and passed both health probes" + exit 0 +fi + +if probe; then + exit 0 +fi + +sleep 10 +if probe; then + exit 0 +fi + +log 'Dashboard or proxy failed two consecutive probes; restarting supervised processes' +docker exec "$container_name" supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy +if wait_for_health; then + log 'Supervised processes recovered and passed both health probes' + exit 0 +fi + +log 'Supervisor recovery failed; restarting the container' +docker restart "$container_name" >/dev/null +wait_for_health +log "Container $container_name recovered and passed both health probes" diff --git a/docker/host/ccs-cliproxy-safe-update.sh b/docker/host/ccs-cliproxy-safe-update.sh new file mode 100755 index 00000000..63f11065 --- /dev/null +++ b/docker/host/ccs-cliproxy-safe-update.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" +lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" +log_file="${CCS_CLIPROXY_UPDATE_LOG:-/var/log/ccs-cliproxy-update.log}" + +log() { + printf '[%s] %s\n' "$(date -Is)" "$*" | tee -a "$log_file" +} + +exec 9>"$lock_file" +if ! flock -n 9; then + log 'Another CLIProxy maintenance operation is active; skipping update check' + exit 0 +fi + +if ! docker inspect "$container_name" >/dev/null 2>&1; then + log "Container $container_name is missing; reconciliation must restore it before updating" + exit 1 +fi + +if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != 'true' ]; then + log "Container $container_name is not running; reconciliation must restore it before updating" + exit 1 +fi + +status_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus 2>&1)" || { + log "Unable to inspect the installed CLIProxy version: $status_output" + exit 1 +} + +if ! grep -qi 'update available' <<<"$status_output"; then + exit 0 +fi + +log 'CLIProxy Plus update available; staging verified replacement while the current proxy stays online' + +if ! docker exec -i "$container_name" sh -s <<'CONTAINER_UPDATE' +set -eu + +live_dir='/root/.ccs/cliproxy/bin/plus' +live_binary="$live_dir/cli-proxy-api-plus" +live_version="$live_dir/.version" +stage_root="$(mktemp -d /root/.ccs/cliproxy/.host-update.XXXXXX)" +stage_ccs_dir="$stage_root/ccs" +stage_dir="$stage_ccs_dir/cliproxy/bin/plus" +stage_binary="$stage_dir/cli-proxy-api-plus" +stage_version="$stage_dir/.version" +backup_binary="$stage_root/previous-binary" +backup_version="$stage_root/previous-version" +swap_started=0 + +supervisorctl_cmd() { + supervisorctl -c /etc/supervisord.conf "$@" +} + +cleanup() { + rm -rf -- "$stage_root" +} + +wait_for_proxy() { + attempts=0 + while [ "$attempts" -lt 30 ]; do + if curl -fsS --max-time 2 http://127.0.0.1:8317/ >/dev/null; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 2 + done + return 1 +} + +rollback() { + supervisorctl_cmd stop cliproxy >/dev/null 2>&1 || true + if [ -f "$backup_binary" ]; then + install -m 0755 "$backup_binary" "$live_binary.rollback" + mv -f "$live_binary.rollback" "$live_binary" + fi + if [ -f "$backup_version" ]; then + install -m 0644 "$backup_version" "$live_version.rollback" + mv -f "$live_version.rollback" "$live_version" + fi + supervisorctl_cmd start cliproxy >/dev/null + wait_for_proxy +} + +on_exit() { + rc=$? + trap - EXIT INT TERM HUP + if [ "$rc" -ne 0 ] && [ "$swap_started" -eq 1 ]; then + rollback || true + fi + cleanup + exit "$rc" +} + +trap on_exit EXIT INT TERM HUP + +mkdir -p "$stage_ccs_dir" +CCS_DIR="$stage_ccs_dir" ccs cliproxy --latest --backend plus +test -x "$stage_binary" +test -s "$stage_version" +"$stage_binary" --version >/dev/null + +cp -p "$live_binary" "$backup_binary" +cp -p "$live_version" "$backup_version" + +supervisorctl_cmd stop cliproxy >/dev/null +swap_started=1 +mv -f "$stage_binary" "$live_binary" +mv -f "$stage_version" "$live_version" +chmod 0755 "$live_binary" +supervisorctl_cmd start cliproxy >/dev/null +wait_for_proxy +swap_started=0 +CONTAINER_UPDATE +then + log 'CLIProxy Plus update failed; previous binary was restored and restarted' + exit 1 +fi + +installed_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus 2>&1)" +log "CLIProxy Plus update completed and passed health verification: $(grep -m1 'Version:' <<<"$installed_output" | xargs)" diff --git a/docker/host/systemd/ccs-cliproxy-reconcile.service b/docker/host/systemd/ccs-cliproxy-reconcile.service new file mode 100644 index 00000000..0ff3730c --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-reconcile.service @@ -0,0 +1,9 @@ +[Unit] +Description=Reconcile the CCS CLIProxy Docker service +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh +TimeoutStartSec=5min diff --git a/docker/host/systemd/ccs-cliproxy-reconcile.timer b/docker/host/systemd/ccs-cliproxy-reconcile.timer new file mode 100644 index 00000000..9c147bea --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-reconcile.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Continuously reconcile the CCS CLIProxy Docker service + +[Timer] +OnBootSec=30s +OnUnitActiveSec=30s +AccuracySec=5s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docker/host/systemd/ccs-cliproxy-update.service b/docker/host/systemd/ccs-cliproxy-update.service new file mode 100644 index 00000000..a4bb64c3 --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Safely update CLIProxy Plus in ccs-cliproxy +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/cliproxy/ccs-cliproxy-safe-update.sh +TimeoutStartSec=10min diff --git a/docker/host/systemd/ccs-cliproxy-update.timer b/docker/host/systemd/ccs-cliproxy-update.timer new file mode 100644 index 00000000..0ac39f3a --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Poll for CLIProxy Plus releases without interrupting the live binary + +[Timer] +OnBootSec=3min +OnUnitActiveSec=15min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index e35753a9..489387fa 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -335,7 +335,6 @@ export async function installCliproxyVersion( const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree; const formatInfo = deps.formatInfo ?? info; const formatWarn = deps.formatWarn ?? warn; - const getInstalledVersion = deps.getInstalledVersion ?? getInstalledCliproxyVersion; // Always attempt a best-effort stop first so we also catch untracked proxies // that are running without a session lock. @@ -352,14 +351,6 @@ export async function installCliproxyVersion( console.log(formatWarn(`Could not stop proxy: ${result.error}`)); } - if (manager.isBinaryInstalled()) { - const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - if (verbose) - console.log( - formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`) - ); - manager.deleteBinary(); - } await manager.ensureBinary(); if (verbose) { diff --git a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts new file mode 100644 index 00000000..3cc16c26 --- /dev/null +++ b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getExecutableName } from '../platform-detector'; +import { downloadAndInstall } from '../installer'; + +describe('atomic binary installation', () => { + let binPath: string; + + beforeEach(() => { + binPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-atomic-installer-')); + }); + + afterEach(() => { + fs.rmSync(binPath, { recursive: true, force: true }); + }); + + function stagingDirectories(): string[] { + return fs.readdirSync(binPath).filter((entry) => entry.startsWith('.cliproxy-install-')); + } + + it('preserves the installed binary and version when verification fails', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: false, + expected: 'expected-checksum', + actual: 'actual-checksum', + }), + extractArchiveFn: async () => { + throw new Error('extract should not run'); + }, + } + ) + ).rejects.toThrow('Checksum mismatch'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); + + it('atomically swaps the verified staged binary and then records its version', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + } + ); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('new-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.7.1'); + expect(stagingDirectories()).toEqual([]); + }); + + it('preserves the installed binary and version when the atomic swap fails', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + renameSyncFn: () => { + throw Object.assign(new Error('replacement blocked'), { code: 'EPERM' }); + }, + } + ) + ).rejects.toThrow('replacement blocked'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); +}); diff --git a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts index ec80035b..9de84ca3 100644 --- a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts +++ b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts @@ -176,6 +176,38 @@ describe('installCliproxyVersion', () => { expect(calls.ensureBinary).toBe(1); }); + it('keeps the installed binary in place while the replacement is prepared', async () => { + const calls = { + deleteBinary: 0, + ensureBinary: 0, + }; + + const binaryManager = await import( + `../../binary-manager?binary-manager-atomic-install=${Date.now()}` + ); + + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + createManager: () => ({ + isBinaryInstalled: () => true, + deleteBinary: () => { + calls.deleteBinary += 1; + }, + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cli-proxy-api-plus'; + }, + }), + stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }), + waitForPortFreeFn: async () => true, + formatInfo: (message: string) => message, + formatWarn: (message: string) => message, + getInstalledVersion: () => '6.6.80', + }); + + expect(calls.deleteBinary).toBe(0); + expect(calls.ensureBinary).toBe(1); + }); + it('waits for the port that was actually stopped before continuing install', async () => { let waitedPort: number | undefined; diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts index 68be9dca..db22362c 100644 --- a/src/cliproxy/binary/installer.ts +++ b/src/cliproxy/binary/installer.ts @@ -19,63 +19,53 @@ import { extractArchive } from './extractor'; import { writeInstalledVersion } from './version-cache'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok } from '../../utils/ui'; +import { BinaryError, NetworkError } from '../../errors/error-types'; + +interface DownloadAndInstallDeps { + downloadWithRetryFn?: typeof downloadWithRetry; + verifyChecksumFn?: typeof verifyChecksum; + extractArchiveFn?: typeof extractArchive; + renameSyncFn?: typeof fs.renameSync; +} /** * Download and install the binary */ export async function downloadAndInstall( config: BinaryManagerConfig, - verbose = false + verbose = false, + deps: DownloadAndInstallDeps = {} ): Promise { const backend = config.backend ?? DEFAULT_BACKEND; const platform = detectPlatform(config.version, backend); const downloadUrl = getDownloadUrl(config.version, backend); const checksumsUrl = getChecksumsUrl(config.version, backend); const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const downloadWithRetryFn = deps.downloadWithRetryFn ?? downloadWithRetry; + const verifyChecksumFn = deps.verifyChecksumFn ?? verifyChecksum; + const extractArchiveFn = deps.extractArchiveFn ?? extractArchive; + const renameSyncFn = deps.renameSyncFn ?? fs.renameSync; fs.mkdirSync(config.binPath, { recursive: true }); - - // Delete existing binary before install to prevent mismatched binaries. - // Abort if binary is currently running (ETXTBSY) — cannot replace in-use binary. - // Happens in Docker when dashboard tries to update while bootstrap's instance is active. - const existingBinary = path.join(config.binPath, getExecutableName(backend)); - if (fs.existsSync(existingBinary)) { - try { - fs.unlinkSync(existingBinary); - if (verbose) console.error(`[cliproxy] Removed existing binary: ${existingBinary}`); - } catch (error: unknown) { - const code = - error instanceof Error && 'code' in error ? (error as { code: string }).code : ''; - // ETXTBSY: Linux-specific error when unlinking a running executable. - // EBUSY on Windows may mean something different (mount point, etc.), - // so only treat ETXTBSY as "binary in use" to avoid misleading messages. - if (code === 'ETXTBSY') { - if (verbose) - console.error(`[cliproxy] Binary is running, cannot replace: ${existingBinary}`); - throw new Error( - 'CLIProxy binary is currently running and cannot be replaced. Restart the container to apply the update.' - ); - } - throw error; - } - } - - const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`); + const stagingPath = fs.mkdtempSync(path.join(config.binPath, '.cliproxy-install-')); + const archivePath = path.join(stagingPath, `cliproxy-archive.${platform.extension}`); + const stagedBinary = path.join(stagingPath, getExecutableName(backend)); + const installedBinary = path.join(config.binPath, getExecutableName(backend)); const spinner = new ProgressIndicator(`Downloading ${backendLabel} v${config.version}`); spinner.start(); try { - const result = await downloadWithRetry(downloadUrl, archivePath, { + const result = await downloadWithRetryFn(downloadUrl, archivePath, { maxRetries: config.maxRetries, verbose, }); if (!result.success) { spinner.fail('Download failed'); - throw new Error(result.error || 'Download failed after retries'); + throw new NetworkError(result.error || 'Download failed after retries', downloadUrl); } spinner.update('Verifying checksum'); - const checksumResult = await verifyChecksum( + const checksumResult = await verifyChecksumFn( archivePath, platform.binaryName, checksumsUrl, @@ -84,29 +74,36 @@ export async function downloadAndInstall( if (!checksumResult.valid) { spinner.fail('Checksum mismatch'); - fs.unlinkSync(archivePath); - throw new Error( + throw new BinaryError( `Checksum mismatch for ${platform.binaryName}\nExpected: ${checksumResult.expected}\n` + - `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}` + `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}`, + stagedBinary ); } spinner.update('Extracting binary'); - await extractArchive(archivePath, config.binPath, platform.extension, verbose, backend); - spinner.succeed(`${backendLabel} ready`); - fs.unlinkSync(archivePath); - - const binaryPath = path.join(config.binPath, getExecutableName(backend)); - if (platform.os !== 'windows' && fs.existsSync(binaryPath)) { - fs.chmodSync(binaryPath, 0o755); - if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`); + await extractArchiveFn(archivePath, stagingPath, platform.extension, verbose, backend); + if (!fs.existsSync(stagedBinary) || !fs.statSync(stagedBinary).isFile()) { + throw new BinaryError( + `Extracted archive did not contain ${getExecutableName(backend)}`, + stagedBinary + ); } + if (platform.os !== 'windows') { + fs.chmodSync(stagedBinary, 0o755); + if (verbose) console.error(`[cliproxy] Set executable permissions: ${stagedBinary}`); + } + + renameSyncFn(stagedBinary, installedBinary); writeInstalledVersion(config.binPath, config.version); + spinner.succeed(`${backendLabel} ready`); console.log(ok(`${backendLabel} v${config.version} installed successfully`)); } catch (error) { spinner.fail('Installation failed'); throw error; + } finally { + fs.rmSync(stagingPath, { recursive: true, force: true }); } } @@ -124,7 +121,10 @@ export function deleteBinary(binPath: string, verbose = false, backend?: CLIProx const code = error instanceof Error && 'code' in error ? (error as { code: string }).code : ''; if (code === 'ETXTBSY') { - throw new Error('CLIProxy binary is currently running and cannot be deleted.'); + throw new BinaryError( + 'CLIProxy binary is currently running and cannot be deleted.', + binaryPath + ); } throw error; } diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index f16b103d..e9ea3802 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -11,7 +11,7 @@ import { isNewerVersion, isVersionFaulty, } from './version-checker'; -import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; +import { downloadAndInstall, getBinaryPath } from './installer'; import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../services/stats-fetcher'; import { resolveLifecyclePort } from '../config/port-manager'; @@ -93,7 +93,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): } else { console.log(info(updateMsg)); console.log(info(`Updating ${backendLabel}...`)); - deleteBinary(config.binPath, verbose, backend); + // The installer stages and validates the replacement before atomically swapping it. config.version = targetVersion; await downloadAndInstall(config, verbose); } diff --git a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts new file mode 100644 index 00000000..dee2a1e3 --- /dev/null +++ b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'bun:test'; + +const updateScript = readFileSync('docker/host/ccs-cliproxy-safe-update.sh', 'utf8'); +const reconcileScript = readFileSync('docker/host/ccs-cliproxy-reconcile.sh', 'utf8'); +const updateService = readFileSync('docker/host/systemd/ccs-cliproxy-update.service', 'utf8'); +const updateTimer = readFileSync('docker/host/systemd/ccs-cliproxy-update.timer', 'utf8'); +const reconcileService = readFileSync('docker/host/systemd/ccs-cliproxy-reconcile.service', 'utf8'); +const reconcileTimer = readFileSync('docker/host/systemd/ccs-cliproxy-reconcile.timer', 'utf8'); + +describe('CLIProxy Docker host continuity assets', () => { + it('stages and validates the replacement before stopping the live proxy', () => { + const stageIndex = updateScript.indexOf('CCS_DIR="$stage_ccs_dir" ccs cliproxy --latest'); + const validateIndex = updateScript.indexOf('"$stage_binary" --version'); + const stopIndex = updateScript.indexOf('supervisorctl_cmd stop cliproxy', validateIndex); + + expect(stageIndex).toBeGreaterThan(-1); + expect(validateIndex).toBeGreaterThan(stageIndex); + expect(stopIndex).toBeGreaterThan(validateIndex); + }); + + it('serializes updates and reconciliation with the same lock', () => { + expect(updateScript).toContain('/run/lock/ccs-cliproxy-maintenance.lock'); + expect(reconcileScript).toContain('/run/lock/ccs-cliproxy-maintenance.lock'); + expect(updateScript).toContain('flock -n 9'); + expect(reconcileScript).toContain('flock -n 9'); + }); + + it('rolls back failed swaps and health-checks both services', () => { + expect(updateScript).toContain('rollback()'); + expect(updateScript).toContain('previous-binary'); + expect(updateScript).toContain('wait_for_proxy'); + expect(reconcileScript).toContain('http://127.0.0.1:3000/'); + expect(reconcileScript).toContain('http://127.0.0.1:8317/'); + }); + + it('recreates missing containers and escalates unhealthy recovery', () => { + expect(reconcileScript).toContain('docker compose -f "$compose_file" up -d --no-build'); + expect(reconcileScript).toContain('restart ccs-dashboard cliproxy'); + expect(reconcileScript).toContain('docker restart "$container_name"'); + }); + + it('installs executable services on bounded timers', () => { + expect(updateService).toContain('ExecStart=/opt/cliproxy/ccs-cliproxy-safe-update.sh'); + expect(updateService).toContain('TimeoutStartSec=10min'); + expect(updateTimer).toContain('OnUnitActiveSec=15min'); + expect(reconcileService).toContain('ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh'); + expect(reconcileTimer).toContain('OnUnitActiveSec=30s'); + }); +}); From 4502e5d503b4ae73dda6187d9044263364019565 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 21:32:22 -0400 Subject: [PATCH 2/7] fix(cliproxy): harden update recovery paths --- docker/host/ccs-cliproxy-reconcile.sh | 21 ++- docker/host/ccs-cliproxy-safe-update.sh | 47 ++++-- .../systemd/ccs-cliproxy-reconcile.service | 2 +- .../__tests__/binary-installer-atomic.test.ts | 159 ++++++++++++++++++ src/cliproxy/binary/installer.ts | 47 +++++- src/cliproxy/binary/lifecycle.ts | 21 ++- .../cliproxy-dashboard-install-service.ts | 51 ++++-- .../cliproxy-host-continuity-assets.test.ts | 8 +- ...cliproxy-dashboard-install-service.test.ts | 14 ++ 9 files changed, 318 insertions(+), 52 deletions(-) diff --git a/docker/host/ccs-cliproxy-reconcile.sh b/docker/host/ccs-cliproxy-reconcile.sh index 0363f449..7f42e062 100755 --- a/docker/host/ccs-cliproxy-reconcile.sh +++ b/docker/host/ccs-cliproxy-reconcile.sh @@ -3,8 +3,9 @@ set -Eeuo pipefail container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" -compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker}" -compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.integrated.yml}" +compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/opt/cliproxy}" +compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.yml}" +compose_project="${CCS_CLIPROXY_COMPOSE_PROJECT:-docker}" lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" log_file="${CCS_CLIPROXY_RECONCILE_LOG:-/var/log/ccs-cliproxy-reconcile.log}" @@ -37,8 +38,10 @@ wait_for_health() { compose_up() { cd "$compose_dir" - docker compose -f "$compose_file" up -d --no-build || \ - docker compose -f "$compose_file" up -d --build + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --no-build || \ + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --build } if ! docker inspect "$container_name" >/dev/null 2>&1; then @@ -67,10 +70,12 @@ if probe; then fi log 'Dashboard or proxy failed two consecutive probes; restarting supervised processes' -docker exec "$container_name" supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy -if wait_for_health; then - log 'Supervised processes recovered and passed both health probes' - exit 0 +if docker exec "$container_name" \ + supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy; then + if wait_for_health; then + log 'Supervised processes recovered and passed both health probes' + exit 0 + fi fi log 'Supervisor recovery failed; restarting the container' diff --git a/docker/host/ccs-cliproxy-safe-update.sh b/docker/host/ccs-cliproxy-safe-update.sh index 63f11065..9dc7e963 100755 --- a/docker/host/ccs-cliproxy-safe-update.sh +++ b/docker/host/ccs-cliproxy-safe-update.sh @@ -26,11 +26,16 @@ if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != 'tru exit 1 fi -status_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus 2>&1)" || { +status_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus --verbose 2>&1)" || { log "Unable to inspect the installed CLIProxy version: $status_output" exit 1 } +if grep -qi 'Could not fetch' <<<"$status_output"; then + log "Unable to check the latest CLIProxy version: $(grep -im1 'Could not fetch' <<<"$status_output" | xargs)" + exit 1 +fi + if ! grep -qi 'update available' <<<"$status_output"; then exit 0 fi @@ -50,7 +55,7 @@ stage_binary="$stage_dir/cli-proxy-api-plus" stage_version="$stage_dir/.version" backup_binary="$stage_root/previous-binary" backup_version="$stage_root/previous-version" -swap_started=0 +maintenance_started=0 supervisorctl_cmd() { supervisorctl -c /etc/supervisord.conf "$@" @@ -73,26 +78,40 @@ wait_for_proxy() { } rollback() { + rollback_ok=1 supervisorctl_cmd stop cliproxy >/dev/null 2>&1 || true if [ -f "$backup_binary" ]; then - install -m 0755 "$backup_binary" "$live_binary.rollback" - mv -f "$live_binary.rollback" "$live_binary" + install -m 0755 "$backup_binary" "$live_binary.rollback" && \ + mv -f "$live_binary.rollback" "$live_binary" || rollback_ok=0 + else + rollback_ok=0 fi if [ -f "$backup_version" ]; then - install -m 0644 "$backup_version" "$live_version.rollback" - mv -f "$live_version.rollback" "$live_version" + install -m 0644 "$backup_version" "$live_version.rollback" && \ + mv -f "$live_version.rollback" "$live_version" || rollback_ok=0 + else + rollback_ok=0 fi - supervisorctl_cmd start cliproxy >/dev/null - wait_for_proxy + supervisorctl_cmd start cliproxy >/dev/null || rollback_ok=0 + wait_for_proxy || rollback_ok=0 + [ "$rollback_ok" -eq 1 ] } on_exit() { rc=$? trap - EXIT INT TERM HUP - if [ "$rc" -ne 0 ] && [ "$swap_started" -eq 1 ]; then - rollback || true + rollback_failed=0 + if [ "$rc" -ne 0 ] && [ "$maintenance_started" -eq 1 ]; then + if ! rollback; then + rollback_failed=1 + printf '[X] CLIProxy rollback failed; recovery files preserved at %s\n' "$stage_root" >&2 + fi + fi + if [ "$rollback_failed" -eq 0 ]; then + cleanup + else + rc=70 fi - cleanup exit "$rc" } @@ -107,17 +126,17 @@ test -s "$stage_version" cp -p "$live_binary" "$backup_binary" cp -p "$live_version" "$backup_version" +maintenance_started=1 supervisorctl_cmd stop cliproxy >/dev/null -swap_started=1 mv -f "$stage_binary" "$live_binary" mv -f "$stage_version" "$live_version" chmod 0755 "$live_binary" supervisorctl_cmd start cliproxy >/dev/null wait_for_proxy -swap_started=0 +maintenance_started=0 CONTAINER_UPDATE then - log 'CLIProxy Plus update failed; previous binary was restored and restarted' + log 'CLIProxy Plus update failed; rollback was attempted and reconciliation will verify service health' exit 1 fi diff --git a/docker/host/systemd/ccs-cliproxy-reconcile.service b/docker/host/systemd/ccs-cliproxy-reconcile.service index 0ff3730c..b1ec3961 100644 --- a/docker/host/systemd/ccs-cliproxy-reconcile.service +++ b/docker/host/systemd/ccs-cliproxy-reconcile.service @@ -6,4 +6,4 @@ Requires=docker.service [Service] Type=oneshot ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh -TimeoutStartSec=5min +TimeoutStartSec=10min diff --git a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts index 3cc16c26..5a1a95ed 100644 --- a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts +++ b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { getExecutableName } from '../platform-detector'; import { downloadAndInstall } from '../installer'; +import { ensureBinary } from '../lifecycle'; describe('atomic binary installation', () => { let binPath: string; @@ -148,4 +149,162 @@ describe('atomic binary installation', () => { expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); expect(stagingDirectories()).toEqual([]); }); + + it('installs a forced version even when an older binary already exists', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + fs.writeFileSync(binaryPath, 'old-binary'); + let installs = 0; + + const resolvedPath = await ensureBinary( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + { + downloadAndInstallFn: async () => { + installs += 1; + }, + } + ); + + expect(resolvedPath).toBe(binaryPath); + expect(installs).toBe(1); + }); + + it('restores the previous binary when publishing the version marker fails', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + let renames = 0; + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + renameSyncFn: (source, destination) => { + renames += 1; + if (renames === 2) throw new Error('version marker blocked'); + fs.renameSync(source, destination); + }, + } + ) + ).rejects.toThrow('version marker blocked'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); + + it('serializes concurrent installs so the binary and version stay paired', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + + const install = (version: string) => + downloadAndInstall( + { + version, + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), `binary-${version}`); + }, + } + ); + + await Promise.all([install('6.7.1'), install('6.7.2')]); + + const installedVersion = fs.readFileSync(versionPath, 'utf8'); + expect(fs.readFileSync(binaryPath, 'utf8')).toBe(`binary-${installedVersion}`); + expect(stagingDirectories()).toEqual([]); + }); + + it('removes staging residue left by an interrupted prior install', async () => { + const stalePath = path.join(binPath, '.cliproxy-install-stale'); + fs.mkdirSync(stalePath); + fs.writeFileSync(path.join(stalePath, 'partial-archive'), 'partial'); + const binaryName = getExecutableName('original'); + + await downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + } + ); + + expect(fs.existsSync(stalePath)).toBe(false); + expect(stagingDirectories()).toEqual([]); + }); }); diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts index db22362c..4b1096e7 100644 --- a/src/cliproxy/binary/installer.ts +++ b/src/cliproxy/binary/installer.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { BinaryManagerConfig } from '../types'; import { detectPlatform, @@ -16,7 +17,6 @@ import { import { downloadWithRetry } from './downloader'; import { verifyChecksum, computeChecksum } from './verifier'; import { extractArchive } from './extractor'; -import { writeInstalledVersion } from './version-cache'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok } from '../../utils/ui'; import { BinaryError, NetworkError } from '../../errors/error-types'; @@ -47,14 +47,29 @@ export async function downloadAndInstall( const renameSyncFn = deps.renameSyncFn ?? fs.renameSync; fs.mkdirSync(config.binPath, { recursive: true }); - const stagingPath = fs.mkdtempSync(path.join(config.binPath, '.cliproxy-install-')); - const archivePath = path.join(stagingPath, `cliproxy-archive.${platform.extension}`); - const stagedBinary = path.join(stagingPath, getExecutableName(backend)); - const installedBinary = path.join(config.binPath, getExecutableName(backend)); + const releaseLock = await lockfile.lock(config.binPath, { + stale: 10 * 60 * 1000, + retries: { retries: 60, factor: 1, minTimeout: 250, maxTimeout: 250 }, + }); + let stagingPath: string | undefined; const spinner = new ProgressIndicator(`Downloading ${backendLabel} v${config.version}`); - spinner.start(); try { + for (const entry of fs.readdirSync(config.binPath)) { + if (entry.startsWith('.cliproxy-install-')) { + fs.rmSync(path.join(config.binPath, entry), { recursive: true, force: true }); + } + } + stagingPath = fs.mkdtempSync(path.join(config.binPath, '.cliproxy-install-')); + const archivePath = path.join(stagingPath, `cliproxy-archive.${platform.extension}`); + const stagedBinary = path.join(stagingPath, getExecutableName(backend)); + const stagedVersion = path.join(stagingPath, '.version'); + const installedBinary = path.join(config.binPath, getExecutableName(backend)); + const installedVersion = path.join(config.binPath, '.version'); + const backupBinary = path.join(stagingPath, '.previous-binary'); + const hadInstalledBinary = fs.existsSync(installedBinary); + spinner.start(); + const result = await downloadWithRetryFn(downloadUrl, archivePath, { maxRetries: config.maxRetries, verbose, @@ -95,15 +110,31 @@ export async function downloadAndInstall( if (verbose) console.error(`[cliproxy] Set executable permissions: ${stagedBinary}`); } + fs.writeFileSync(stagedVersion, config.version, 'utf8'); + if (hadInstalledBinary) { + fs.copyFileSync(installedBinary, backupBinary); + if (platform.os !== 'windows') fs.chmodSync(backupBinary, 0o755); + } + renameSyncFn(stagedBinary, installedBinary); - writeInstalledVersion(config.binPath, config.version); + try { + renameSyncFn(stagedVersion, installedVersion); + } catch (error) { + if (hadInstalledBinary) { + renameSyncFn(backupBinary, installedBinary); + } else { + fs.unlinkSync(installedBinary); + } + throw error; + } spinner.succeed(`${backendLabel} ready`); console.log(ok(`${backendLabel} v${config.version} installed successfully`)); } catch (error) { spinner.fail('Installation failed'); throw error; } finally { - fs.rmSync(stagingPath, { recursive: true, force: true }); + if (stagingPath) fs.rmSync(stagingPath, { recursive: true, force: true }); + await releaseLock(); } } diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index e9ea3802..c5d13c94 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -15,6 +15,7 @@ import { downloadAndInstall, getBinaryPath } from './installer'; import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../services/stats-fetcher'; import { resolveLifecyclePort } from '../config/port-manager'; +import { BinaryError } from '../../errors/error-types'; import { CLIPROXY_MAX_STABLE_VERSION, CLIPROXY_FAULTY_RANGE, @@ -103,17 +104,26 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): * Ensure binary is available (download if missing, update if outdated) * @returns Path to executable binary */ -export async function ensureBinary(config: BinaryManagerConfig): Promise { +interface EnsureBinaryDeps { + downloadAndInstallFn?: typeof downloadAndInstall; +} + +export async function ensureBinary( + config: BinaryManagerConfig, + deps: EnsureBinaryDeps = {} +): Promise { const verbose = config.verbose; const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const binaryPath = getBinaryPath(config.binPath, backend); + const downloadAndInstallFn = deps.downloadAndInstallFn ?? downloadAndInstall; // Binary exists - check for updates unless forceVersion if (fs.existsSync(binaryPath)) { log(`Binary exists: ${binaryPath}`, verbose); if (config.forceVersion) { - log('Force version mode: skipping auto-update', verbose); + log(`Force version mode: installing specified version ${config.version}`, verbose); + await downloadAndInstallFn(config, verbose); return binaryPath; } @@ -134,9 +144,10 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise // Binary missing if (!config.allowInstall) { - throw new Error( + throw new BinaryError( `${getBackendLabel(backend)} binary is not installed locally. ` + - 'Run "ccs cliproxy install" when you have network access.' + 'Run "ccs cliproxy install" when you have network access.', + binaryPath ); } @@ -161,6 +172,6 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise log(`Force version mode: using specified version ${config.version}`, verbose); } - await downloadAndInstall(config, verbose); + await downloadAndInstallFn(config, verbose); return binaryPath; } diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 029bd472..dd7cc9e9 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -4,6 +4,7 @@ import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/s import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher'; import type { CLIProxyBackend } from '../../cliproxy/types'; +import { ProxyError } from '../../errors/error-types'; import { isRunningUnderSupervisord, restartCliproxyViaSupervisord, @@ -22,6 +23,8 @@ interface InstallDashboardCliproxyVersionDeps { backend?: CLIProxyBackend ) => Promise; ensureCliproxyService: () => Promise; + isRunningUnderSupervisord?: () => boolean; + restartCliproxyViaSupervisord?: typeof restartCliproxyViaSupervisord; } const defaultDeps: InstallDashboardCliproxyVersionDeps = { @@ -48,6 +51,23 @@ async function wasProxyRunning(deps: InstallDashboardCliproxyVersionDeps): Promi return deps.isCliproxyRunning(); } +async function restoreProxyService( + deps: InstallDashboardCliproxyVersionDeps +): Promise { + const underSupervisord = deps.isRunningUnderSupervisord?.() ?? isRunningUnderSupervisord(); + if (underSupervisord) { + const restart = deps.restartCliproxyViaSupervisord?.() ?? restartCliproxyViaSupervisord(); + return { + started: restart.success, + alreadyRunning: false, + port: restart.port ?? resolveLifecyclePort(), + error: restart.error, + }; + } + + return deps.ensureCliproxyService(); +} + export async function installDashboardCliproxyVersion( version: string, backend: CLIProxyBackend, @@ -59,7 +79,21 @@ export async function installDashboardCliproxyVersion( // The installer owns the stop-and-replace lifecycle, including best-effort // shutdown for tracked and untracked proxies before swapping the binary. - await deps.installCliproxyVersion(version, true, effectiveBackend); + try { + await deps.installCliproxyVersion(version, true, effectiveBackend); + } catch (error) { + if (shouldRestoreService) { + const restoreResult = await restoreProxyService(deps); + if (!restoreResult.started && !restoreResult.alreadyRunning) { + const installMessage = error instanceof Error ? error.message : String(error); + throw new ProxyError( + `${installMessage}; previous ${backendLabel} service also failed to restart: ${restoreResult.error ?? 'unknown restart error'}`, + restoreResult.port + ); + } + } + throw error; + } if (!shouldRestoreService) { return { @@ -70,20 +104,7 @@ export async function installDashboardCliproxyVersion( } // In Docker, supervisord owns process lifecycle — delegate restart to it - if (isRunningUnderSupervisord()) { - const result = restartCliproxyViaSupervisord(); - return { - success: result.success, - restarted: result.success, - port: result.port, - error: result.error, - message: result.success - ? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}` - : `Installed ${backendLabel} v${version}, but restart failed`, - }; - } - - const startResult = await deps.ensureCliproxyService(); + const startResult = await restoreProxyService(deps); if (!startResult.started && !startResult.alreadyRunning) { return { success: false, diff --git a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts index dee2a1e3..df0390e7 100644 --- a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts +++ b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts @@ -35,9 +35,15 @@ describe('CLIProxy Docker host continuity assets', () => { }); it('recreates missing containers and escalates unhealthy recovery', () => { - expect(reconcileScript).toContain('docker compose -f "$compose_file" up -d --no-build'); + expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_DIR:-/opt/cliproxy'); + expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_PROJECT:-docker'); + expect(reconcileScript).toContain('--project-name "$compose_project"'); + expect(reconcileScript).toContain('-f "$compose_file" up -d --no-build'); expect(reconcileScript).toContain('restart ccs-dashboard cliproxy'); expect(reconcileScript).toContain('docker restart "$container_name"'); + expect(reconcileScript.indexOf('docker restart "$container_name"')).toBeGreaterThan( + reconcileScript.indexOf('restart ccs-dashboard cliproxy') + ); }); it('installs executable services on bounded timers', () => { diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 5805ed54..fc6a61cf 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -10,6 +10,7 @@ function createDeps( sessionRunning?: boolean; remoteRunning?: boolean; startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string }; + installError?: Error; } = {} ) { const calls = { @@ -30,6 +31,7 @@ function createDeps( _backend?: CLIProxyBackend ) => { calls.installCliproxyVersion += 1; + if (overrides.installError) throw overrides.installError; }, ensureCliproxyService: async () => { calls.ensureCliproxyService += 1; @@ -122,4 +124,16 @@ describe('installDashboardCliproxyVersion', () => { message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it', }); }); + + it('restores a previously running proxy when installation fails', async () => { + const { deps, calls } = createDeps({ + sessionRunning: true, + installError: new Error('checksum mismatch'), + }); + + await expect(installDashboardCliproxyVersion('6.7.1', 'plus', deps)).rejects.toThrow( + 'checksum mismatch' + ); + expect(calls.ensureCliproxyService).toBe(1); + }); }); From 192b27fbb22b24c3b8741957986b5a2712e64086 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 21:32:33 -0400 Subject: [PATCH 3/7] chore(hardening): refresh source inventory --- docs/reports/hardening-inventory.json | 53 +++++++++++++-------------- docs/reports/hardening-inventory.md | 10 ++--- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 1a238299..e810f90d 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,9 +1,9 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2471, - "filesAffected": 261, - "hotpathOccurrences": 1186, + "totalOccurrences": 2514, + "filesAffected": 262, + "hotpathOccurrences": 1192, "hotpathFilesAffected": 155, "topHotpathFiles": [ { @@ -247,6 +247,21 @@ ], "markers": [] }, + { + "file": "src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts", + "count": 37, + "calls": [ + "existsSync", + "mkdirSync", + "mkdtempSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", "count": 36, @@ -286,22 +301,6 @@ "writeFileSync" ], "markers": [] - }, - { - "file": "src/utils/browser/mcp-installer.ts", - "count": 32, - "calls": [ - "chmodSync", - "copyFileSync", - "existsSync", - "mkdirSync", - "readFileSync", - "renameSync", - "statSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] } ] }, @@ -497,16 +496,16 @@ }, "maintainability": { "typedErrors": { - "totalThrows": 454, - "typedThrows": 83, - "plainThrows": 311, + "totalThrows": 455, + "typedThrows": 89, + "plainThrows": 306, "otherThrows": 60, - "adoptionRatio": 0.1828, + "adoptionRatio": 0.1956, "topSubdomainsByThrows": [ { "subdomain": "web-server", - "count": 103, - "typed": 16, + "count": 104, + "typed": 17, "plain": 45 }, { @@ -652,9 +651,9 @@ ] }, "hotpathConsoleErrors": { - "totalOccurrences": 592, + "totalOccurrences": 590, "exemptOccurrences": 326, - "hotpathOccurrences": 266, + "hotpathOccurrences": 264, "filesAffected": 81, "topFiles": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 6928164e..90a6d9e1 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,9 +6,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2471 | -| Sync fs files affected (all) | 261 | -| Sync fs occurrences (runtime hotpaths) | 1186 | +| Sync fs occurrences (all) | 2514 | +| Sync fs files affected (all) | 262 | +| Sync fs occurrences (runtime hotpaths) | 1192 | | Sync fs files affected (runtime hotpaths) | 155 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -56,9 +56,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| typed-error adoption (typed/total throws) | 18.3% (83/454) | +| typed-error adoption (typed/total throws) | 19.6% (89/455) | | typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | -| hotpath console.error/warn occurrences | 266 (592 total, 326 CLI-UX exempt) | +| hotpath console.error/warn occurrences | 264 (590 total, 326 CLI-UX exempt) | | hotpath console.error/warn files | 81 | | files with createLogger | 65/764 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | From fc56ecaac4ed8218f5d0408a6c78f173a11a78da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 22:05:18 -0400 Subject: [PATCH 4/7] fix(cliproxy): finish concurrent update recovery --- docker/host/ccs-cliproxy-reconcile.sh | 20 ++++- docker/host/ccs-cliproxy-safe-update.sh | 45 +++++++++- src/cliproxy/binary-manager.ts | 57 ++++++++---- .../__tests__/binary-installer-atomic.test.ts | 49 +++++++++++ .../__tests__/binary-manager-install.test.ts | 4 + src/cliproxy/binary/install-lifecycle-lock.ts | 37 ++++++++ src/cliproxy/binary/installer.ts | 17 +++- src/cliproxy/binary/lifecycle.ts | 7 +- src/cliproxy/services/binary-service.ts | 49 ++++++----- .../__tests__/types-backward-compat.test.ts | 1 + src/cliproxy/types/binary-types.ts | 1 + .../cliproxy-dashboard-install-service.ts | 87 ++++++++++--------- .../cliproxy-host-continuity-assets.test.ts | 86 +++++++++++++++++- ...cliproxy-dashboard-install-service.test.ts | 55 ++++++++++++ 14 files changed, 428 insertions(+), 87 deletions(-) create mode 100644 src/cliproxy/binary/install-lifecycle-lock.ts diff --git a/docker/host/ccs-cliproxy-reconcile.sh b/docker/host/ccs-cliproxy-reconcile.sh index 7f42e062..ec67ca0b 100755 --- a/docker/host/ccs-cliproxy-reconcile.sh +++ b/docker/host/ccs-cliproxy-reconcile.sh @@ -44,6 +44,14 @@ compose_up() { -f "$compose_file" up -d --build } +compose_recreate() { + cd "$compose_dir" + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --force-recreate --no-build || \ + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --force-recreate --build +} + if ! docker inspect "$container_name" >/dev/null 2>&1; then log "Container $container_name is missing; recreating from $compose_file" compose_up @@ -79,6 +87,14 @@ if docker exec "$container_name" \ fi log 'Supervisor recovery failed; restarting the container' -docker restart "$container_name" >/dev/null +if docker restart "$container_name" >/dev/null; then + if wait_for_health; then + log "Container $container_name recovered and passed both health probes" + exit 0 + fi +fi + +log "Container $container_name is still unhealthy; recreating it from $compose_file" +compose_recreate wait_for_health -log "Container $container_name recovered and passed both health probes" +log "Container $container_name was recreated and passed both health probes" diff --git a/docker/host/ccs-cliproxy-safe-update.sh b/docker/host/ccs-cliproxy-safe-update.sh index 9dc7e963..202ea93e 100755 --- a/docker/host/ccs-cliproxy-safe-update.sh +++ b/docker/host/ccs-cliproxy-safe-update.sh @@ -55,6 +55,10 @@ stage_binary="$stage_dir/cli-proxy-api-plus" stage_version="$stage_dir/.version" backup_binary="$stage_root/previous-binary" backup_version="$stage_root/previous-version" +install_lock_target='/root/.ccs/cliproxy/bin/.install-lifecycle-plus' +install_lock_dir="$install_lock_target.lock" +install_lock_stale_seconds=600 +install_lock_owned=0 maintenance_started=0 supervisorctl_cmd() { @@ -65,6 +69,40 @@ cleanup() { rm -rf -- "$stage_root" } +remove_stale_install_lock() { + lock_mtime="$( + stat -c %Y "$install_lock_dir" 2>/dev/null || stat -f %m "$install_lock_dir" 2>/dev/null + )" || return 0 + current_time="$(date +%s)" + lock_age=$((current_time - lock_mtime)) + if [ "$lock_age" -gt "$install_lock_stale_seconds" ]; then + rmdir "$install_lock_dir" 2>/dev/null || true + fi +} + +acquire_install_lock() { + mkdir -p "$install_lock_target" + attempts=0 + while ! mkdir "$install_lock_dir" 2>/dev/null; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 240 ]; then + printf '[X] Timed out waiting for CLIProxy install lifecycle lock\n' >&2 + return 1 + fi + remove_stale_install_lock + sleep 0.25 + done + install_lock_owned=1 + touch "$install_lock_dir" +} + +release_install_lock() { + if [ "$install_lock_owned" -eq 1 ]; then + rmdir "$install_lock_dir" 2>/dev/null || true + install_lock_owned=0 + fi +} + wait_for_proxy() { attempts=0 while [ "$attempts" -lt 30 ]; do @@ -107,6 +145,7 @@ on_exit() { printf '[X] CLIProxy rollback failed; recovery files preserved at %s\n' "$stage_root" >&2 fi fi + release_install_lock if [ "$rollback_failed" -eq 0 ]; then cleanup else @@ -115,7 +154,10 @@ on_exit() { exit "$rc" } -trap on_exit EXIT INT TERM HUP +trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP mkdir -p "$stage_ccs_dir" CCS_DIR="$stage_ccs_dir" ccs cliproxy --latest --backend plus @@ -123,6 +165,7 @@ test -x "$stage_binary" test -s "$stage_version" "$stage_binary" --version >/dev/null +acquire_install_lock cp -p "$live_binary" "$backup_binary" cp -p "$live_version" "$backup_version" diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 489387fa..6698fc61 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -41,6 +41,7 @@ import { import type { CLIProxyBackend } from './types'; import { getVersionListCachePath } from './binary/version-cache'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { withInstallLifecycleLock } from './binary/install-lifecycle-lock'; export const CLIPROXY_DELETED_PLUS_REPO = 'router-for-me/CLIProxyAPIPlus'; export const CLIPROXY_PLUS_FALLBACK_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; @@ -176,6 +177,14 @@ function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string { return `${baseDir}/${backend}`; } +export async function withCliproxyInstallLifecycleLock( + backend: CLIProxyBackend, + operation: () => Promise +): Promise { + const lockTarget = path.join(getBinDir(), `.install-lifecycle-${backend}`); + return withInstallLifecycleLock(lockTarget, operation); +} + /** Default configuration (uses backend from config.yaml or defaults to `DEFAULT_BACKEND`) */ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig { const backendConfig = BACKEND_CONFIG[backend]; @@ -186,6 +195,7 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary maxRetries: 3, verbose: false, forceVersion: false, + replaceExisting: false, skipAutoUpdate: false, allowInstall: true, backend, // Pass backend for installer to use correct download URL @@ -317,6 +327,7 @@ interface InstallCliproxyVersionDeps { formatInfo?: typeof info; formatWarn?: typeof warn; getInstalledVersion?: typeof getInstalledCliproxyVersion; + withInstallLifecycleLockFn?: typeof withCliproxyInstallLifecycleLock; } /** Install a specific version of CLIProxyAPI */ @@ -329,33 +340,41 @@ export async function installCliproxyVersion( const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const manager = - deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? - new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + deps.createManager?.( + { version, verbose, forceVersion: true, replaceExisting: true }, + effectiveBackend + ) ?? + new BinaryManager( + { version, verbose, forceVersion: true, replaceExisting: true }, + effectiveBackend + ); const stopProxyFn = deps.stopProxyFn ?? stopProxy; const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree; const formatInfo = deps.formatInfo ?? info; const formatWarn = deps.formatWarn ?? warn; + const withLifecycleLock = deps.withInstallLifecycleLockFn ?? withCliproxyInstallLifecycleLock; - // Always attempt a best-effort stop first so we also catch untracked proxies - // that are running without a session lock. - if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); - const result = await stopProxyFn(); - if (result.stopped) { - const stoppedPort = result.port ?? resolveLifecyclePort(); - // Wait for port to be fully released - const portFree = await waitForPortFreeFn(stoppedPort, 5000); - if (!portFree && verbose) { - console.log(formatWarn('Port did not free up in time, proceeding anyway...')); + await withLifecycleLock(effectiveBackend, async () => { + // Always attempt a best-effort stop first so we also catch untracked proxies + // that are running without a session lock. + if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); + const result = await stopProxyFn(); + if (result.stopped) { + const stoppedPort = result.port ?? resolveLifecyclePort(); + const portFree = await waitForPortFreeFn(stoppedPort, 5000); + if (!portFree && verbose) { + console.log(formatWarn('Port did not free up in time, proceeding anyway...')); + } + } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { + console.log(formatWarn(`Could not stop proxy: ${result.error}`)); } - } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { - console.log(formatWarn(`Could not stop proxy: ${result.error}`)); - } - await manager.ensureBinary(); + await manager.ensureBinary(); - if (verbose) { - console.log(formatInfo('New version will be active on next CLIProxy command')); - } + if (verbose) { + console.log(formatInfo('New version will be active on next CLIProxy command')); + } + }); } /** Fetch the latest CLIProxyAPI version from GitHub API */ diff --git a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts index 5a1a95ed..3a791ad3 100644 --- a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts +++ b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { getExecutableName } from '../platform-detector'; import { downloadAndInstall } from '../installer'; import { ensureBinary } from '../lifecycle'; +import { withInstallLifecycleLock } from '../install-lifecycle-lock'; describe('atomic binary installation', () => { let binPath: string; @@ -78,6 +79,7 @@ describe('atomic binary installation', () => { maxRetries: 1, verbose: false, forceVersion: true, + replaceExisting: true, skipAutoUpdate: false, allowInstall: true, backend: 'original', @@ -163,6 +165,7 @@ describe('atomic binary installation', () => { maxRetries: 1, verbose: false, forceVersion: true, + replaceExisting: true, skipAutoUpdate: false, allowInstall: true, backend: 'original', @@ -178,6 +181,52 @@ describe('atomic binary installation', () => { expect(installs).toBe(1); }); + it('reuses an existing pinned binary during runtime bootstrap', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + fs.writeFileSync(binaryPath, 'pinned-binary'); + let installs = 0; + + const resolvedPath = await ensureBinary( + { + version: '6.6.80', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + replaceExisting: false, + skipAutoUpdate: false, + allowInstall: false, + backend: 'original', + }, + { + downloadAndInstallFn: async () => { + installs += 1; + }, + } + ); + + expect(resolvedPath).toBe(binaryPath); + expect(installs).toBe(0); + }); + + it('waits for an externally held compatible install lifecycle lock', async () => { + const lockTarget = path.join(binPath, '.install-lifecycle-plus'); + fs.mkdirSync(lockTarget, { recursive: true }); + fs.mkdirSync(`${lockTarget}.lock`); + let entered = false; + + const operation = withInstallLifecycleLock(lockTarget, async () => { + entered = true; + }); + + await Bun.sleep(50); + expect(entered).toBe(false); + fs.rmdirSync(`${lockTarget}.lock`); + await operation; + expect(entered).toBe(true); + }); + it('restores the previous binary when publishing the version marker fails', async () => { const binaryName = getExecutableName('original'); const binaryPath = path.join(binPath, binaryName); diff --git a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts index 9de84ca3..626360e3 100644 --- a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts +++ b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts @@ -33,6 +33,7 @@ describe('installCliproxyVersion', () => { ); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: (_config: unknown, backend: string) => { seenBackend = backend; return { @@ -147,6 +148,7 @@ describe('installCliproxyVersion', () => { const binaryManager = await import(`../../binary-manager?binary-manager-install=${Date.now()}`); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: () => ({ isBinaryInstalled: () => false, deleteBinary: () => { @@ -187,6 +189,7 @@ describe('installCliproxyVersion', () => { ); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: () => ({ isBinaryInstalled: () => true, deleteBinary: () => { @@ -216,6 +219,7 @@ describe('installCliproxyVersion', () => { ); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: () => ({ isBinaryInstalled: () => false, deleteBinary: () => undefined, diff --git a/src/cliproxy/binary/install-lifecycle-lock.ts b/src/cliproxy/binary/install-lifecycle-lock.ts new file mode 100644 index 00000000..afa534b2 --- /dev/null +++ b/src/cliproxy/binary/install-lifecycle-lock.ts @@ -0,0 +1,37 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; + +const heldInstallLocks = new AsyncLocalStorage>(); + +export async function withInstallLifecycleLock( + lockTarget: string, + operation: () => Promise +): Promise { + const resolvedTarget = path.resolve(lockTarget); + const held = heldInstallLocks.getStore(); + if (held?.has(resolvedTarget)) return operation(); + + fs.mkdirSync(resolvedTarget, { recursive: true }); + const release = await lockfile.lock(resolvedTarget, { + stale: 10 * 60 * 1000, + retries: { retries: 60, factor: 1, minTimeout: 250, maxTimeout: 250 }, + }); + const nextHeld = new Set(held); + nextHeld.add(resolvedTarget); + let operationError: unknown; + + try { + return await heldInstallLocks.run(nextHeld, operation); + } catch (error) { + operationError = error; + throw error; + } finally { + try { + await release(); + } catch (error) { + if (!operationError) throw error; + } + } +} diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts index 4b1096e7..d79914c4 100644 --- a/src/cliproxy/binary/installer.ts +++ b/src/cliproxy/binary/installer.ts @@ -53,6 +53,7 @@ export async function downloadAndInstall( }); let stagingPath: string | undefined; const spinner = new ProgressIndicator(`Downloading ${backendLabel} v${config.version}`); + let installError: unknown; try { for (const entry of fs.readdirSync(config.binPath)) { @@ -130,11 +131,23 @@ export async function downloadAndInstall( spinner.succeed(`${backendLabel} ready`); console.log(ok(`${backendLabel} v${config.version} installed successfully`)); } catch (error) { + installError = error; spinner.fail('Installation failed'); throw error; } finally { - if (stagingPath) fs.rmSync(stagingPath, { recursive: true, force: true }); - await releaseLock(); + let cleanupError: unknown; + try { + if (stagingPath) fs.rmSync(stagingPath, { recursive: true, force: true }); + } catch (error) { + cleanupError = error; + } finally { + try { + await releaseLock(); + } catch (error) { + cleanupError ??= error; + } + } + if (!installError && cleanupError) throw cleanupError; } } diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index c5d13c94..b77eaa80 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -121,12 +121,17 @@ export async function ensureBinary( if (fs.existsSync(binaryPath)) { log(`Binary exists: ${binaryPath}`, verbose); - if (config.forceVersion) { + if (config.replaceExisting) { log(`Force version mode: installing specified version ${config.version}`, verbose); await downloadAndInstallFn(config, verbose); return binaryPath; } + if (config.forceVersion) { + log(`Pinned version mode: using installed version ${config.version}`, verbose); + return binaryPath; + } + if (config.skipAutoUpdate) { log('Runtime bootstrap mode: skipping auto-update check', verbose); return binaryPath; diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 7c91fd09..445aab1f 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -19,6 +19,7 @@ import { clearPinnedVersion, isVersionPinned, resolveLocalBackend, + withCliproxyInstallLifecycleLock, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../binary/platform-detector'; import { CLIProxyBackend } from '../types'; @@ -127,14 +128,16 @@ export async function installVersion( const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { - await installCliproxyVersion(version, verbose, effectiveBackend); - savePinnedVersion(version, effectiveBackend); + return await withCliproxyInstallLifecycleLock(effectiveBackend, async () => { + await installCliproxyVersion(version, verbose, effectiveBackend); + savePinnedVersion(version, effectiveBackend); - return { - success: true, - version, - wasPinned: true, - }; + return { + success: true, + version, + wasPinned: true, + }; + }); } catch (error) { return { success: false, @@ -156,26 +159,28 @@ export async function installLatest( const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { - const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); - const currentVersion = getInstalledCliproxyVersion(effectiveBackend); - const wasPinned = isVersionPinned(effectiveBackend); + return await withCliproxyInstallLifecycleLock(effectiveBackend, async () => { + const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); + const currentVersion = getInstalledCliproxyVersion(effectiveBackend); + const wasPinned = isVersionPinned(effectiveBackend); + + if (isCLIProxyInstalled(effectiveBackend) && latestVersion === currentVersion && !wasPinned) { + return { + success: true, + version: latestVersion, + error: `Already running latest version: v${latestVersion}`, + }; + } + + await installCliproxyVersion(latestVersion, verbose, effectiveBackend); + clearPinnedVersion(effectiveBackend); - if (isCLIProxyInstalled(effectiveBackend) && latestVersion === currentVersion && !wasPinned) { return { success: true, version: latestVersion, - error: `Already running latest version: v${latestVersion}`, + wasPinned, }; - } - - await installCliproxyVersion(latestVersion, verbose, effectiveBackend); - clearPinnedVersion(effectiveBackend); - - return { - success: true, - version: latestVersion, - wasPinned, - }; + }); } catch (error) { return { success: false, diff --git a/src/cliproxy/types/__tests__/types-backward-compat.test.ts b/src/cliproxy/types/__tests__/types-backward-compat.test.ts index 95952fb7..eef2705c 100644 --- a/src/cliproxy/types/__tests__/types-backward-compat.test.ts +++ b/src/cliproxy/types/__tests__/types-backward-compat.test.ts @@ -56,6 +56,7 @@ describe('types.ts backward compatibility', () => { maxRetries: 3, verbose: false, forceVersion: false, + replaceExisting: false, skipAutoUpdate: false, allowInstall: true, }; diff --git a/src/cliproxy/types/binary-types.ts b/src/cliproxy/types/binary-types.ts index 86bdbcb1..db7b1445 100644 --- a/src/cliproxy/types/binary-types.ts +++ b/src/cliproxy/types/binary-types.ts @@ -14,6 +14,7 @@ export interface BinaryManagerConfig { maxRetries: number; verbose: boolean; forceVersion: boolean; + replaceExisting?: boolean; skipAutoUpdate: boolean; allowInstall: boolean; backend?: CLIProxyBackend; diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index dd7cc9e9..1a567a0f 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,4 +1,8 @@ -import { installCliproxyVersion, resolveLocalBackend } from '../../cliproxy/binary-manager'; +import { + installCliproxyVersion, + resolveLocalBackend, + withCliproxyInstallLifecycleLock, +} from '../../cliproxy/binary-manager'; import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager'; import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; @@ -25,6 +29,7 @@ interface InstallDashboardCliproxyVersionDeps { ensureCliproxyService: () => Promise; isRunningUnderSupervisord?: () => boolean; restartCliproxyViaSupervisord?: typeof restartCliproxyViaSupervisord; + withInstallLifecycleLock?: typeof withCliproxyInstallLifecycleLock; } const defaultDeps: InstallDashboardCliproxyVersionDeps = { @@ -75,49 +80,53 @@ export async function installDashboardCliproxyVersion( ): Promise { const effectiveBackend = resolveLocalBackend(backend, { notifyOnPlus: true }); const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - const shouldRestoreService = await wasProxyRunning(deps); + const withLifecycleLock = deps.withInstallLifecycleLock ?? withCliproxyInstallLifecycleLock; - // The installer owns the stop-and-replace lifecycle, including best-effort - // shutdown for tracked and untracked proxies before swapping the binary. - try { - await deps.installCliproxyVersion(version, true, effectiveBackend); - } catch (error) { - if (shouldRestoreService) { - const restoreResult = await restoreProxyService(deps); - if (!restoreResult.started && !restoreResult.alreadyRunning) { - const installMessage = error instanceof Error ? error.message : String(error); - throw new ProxyError( - `${installMessage}; previous ${backendLabel} service also failed to restart: ${restoreResult.error ?? 'unknown restart error'}`, - restoreResult.port - ); + return withLifecycleLock(effectiveBackend, async () => { + const shouldRestoreService = await wasProxyRunning(deps); + + // The installer owns the stop-and-replace lifecycle, including best-effort + // shutdown for tracked and untracked proxies before swapping the binary. + try { + await deps.installCliproxyVersion(version, true, effectiveBackend); + } catch (error) { + if (shouldRestoreService) { + const restoreResult = await restoreProxyService(deps); + if (!restoreResult.started && !restoreResult.alreadyRunning) { + const installMessage = error instanceof Error ? error.message : String(error); + throw new ProxyError( + `${installMessage}; previous ${backendLabel} service also failed to restart: ${restoreResult.error ?? 'unknown restart error'}`, + restoreResult.port + ); + } } + throw error; + } + + if (!shouldRestoreService) { + return { + success: true, + restarted: false, + message: `Successfully installed ${backendLabel} v${version}`, + }; + } + + // In Docker, supervisord owns process lifecycle — delegate restart to it + const startResult = await restoreProxyService(deps); + if (!startResult.started && !startResult.alreadyRunning) { + return { + success: false, + restarted: false, + error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`, + message: `Installed ${backendLabel} v${version}, but failed to restart it`, + }; } - throw error; - } - if (!shouldRestoreService) { return { success: true, - restarted: false, - message: `Successfully installed ${backendLabel} v${version}`, + restarted: true, + port: startResult.port, + message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`, }; - } - - // In Docker, supervisord owns process lifecycle — delegate restart to it - const startResult = await restoreProxyService(deps); - if (!startResult.started && !startResult.alreadyRunning) { - return { - success: false, - restarted: false, - error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`, - message: `Installed ${backendLabel} v${version}, but failed to restart it`, - }; - } - - return { - success: true, - restarted: true, - port: startResult.port, - message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`, - }; + }); } diff --git a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts index df0390e7..d2111982 100644 --- a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts +++ b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts @@ -1,5 +1,8 @@ -import { readFileSync } from 'fs'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { spawnSync } from 'child_process'; import { describe, expect, it } from 'bun:test'; +import { tmpdir } from 'os'; +import { join } from 'path'; const updateScript = readFileSync('docker/host/ccs-cliproxy-safe-update.sh', 'utf8'); const reconcileScript = readFileSync('docker/host/ccs-cliproxy-reconcile.sh', 'utf8'); @@ -26,6 +29,54 @@ describe('CLIProxy Docker host continuity assets', () => { expect(reconcileScript).toContain('flock -n 9'); }); + it('shares the in-container install lifecycle lock with CLI and dashboard installs', () => { + expect(updateScript).toContain( + "install_lock_target='/root/.ccs/cliproxy/bin/.install-lifecycle-plus'" + ); + expect(updateScript).toContain('install_lock_dir="$install_lock_target.lock"'); + expect(updateScript).toContain('while ! mkdir "$install_lock_dir"'); + expect(updateScript).toContain('touch "$install_lock_dir"'); + expect(updateScript).toContain('install_lock_stale_seconds=600'); + expect(updateScript).toContain('remove_stale_install_lock'); + expect(updateScript).toContain('rmdir "$install_lock_dir"'); + expect(updateScript.indexOf('acquire_install_lock')).toBeLessThan( + updateScript.indexOf('supervisorctl_cmd stop cliproxy') + ); + }); + + it('reclaims an orphaned stale lifecycle lock', () => { + const testRoot = mkdtempSync(join(tmpdir(), 'ccs-cliproxy-host-lock-')); + const helpersStart = updateScript.indexOf('remove_stale_install_lock() {'); + const helpersEnd = updateScript.indexOf('\nwait_for_proxy() {'); + const helpers = updateScript.slice(helpersStart, helpersEnd); + try { + const result = spawnSync( + 'bash', + [ + '-c', + `set -Eeuo pipefail +install_lock_target="$1/target" +install_lock_dir="$install_lock_target.lock" +install_lock_stale_seconds=600 +install_lock_owned=0 +mkdir -p "$install_lock_target" "$install_lock_dir" +touch -t 200001010000 "$install_lock_dir" +${helpers} +acquire_install_lock +test "$install_lock_owned" -eq 1 +release_install_lock`, + 'test-shell', + testRoot, + ], + { encoding: 'utf8' } + ); + + expect(result.status).toBe(0); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + it('rolls back failed swaps and health-checks both services', () => { expect(updateScript).toContain('rollback()'); expect(updateScript).toContain('previous-binary'); @@ -41,6 +92,7 @@ describe('CLIProxy Docker host continuity assets', () => { expect(reconcileScript).toContain('-f "$compose_file" up -d --no-build'); expect(reconcileScript).toContain('restart ccs-dashboard cliproxy'); expect(reconcileScript).toContain('docker restart "$container_name"'); + expect(reconcileScript).toContain('up -d --force-recreate --no-build'); expect(reconcileScript.indexOf('docker restart "$container_name"')).toBeGreaterThan( reconcileScript.indexOf('restart ccs-dashboard cliproxy') ); @@ -53,4 +105,36 @@ describe('CLIProxy Docker host continuity assets', () => { expect(reconcileService).toContain('ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh'); expect(reconcileTimer).toContain('OnUnitActiveSec=30s'); }); + + it('forces nonzero signal exits so the EXIT trap rolls back maintenance', () => { + expect(updateScript).toContain("trap 'exit 130' INT"); + expect(updateScript).toContain("trap 'exit 143' TERM"); + expect(updateScript).toContain("trap 'exit 129' HUP"); + + const handlerStart = updateScript.indexOf('on_exit() {'); + const handlerEnd = updateScript.indexOf('\ntrap on_exit EXIT'); + const handlers = updateScript.slice(handlerStart, handlerEnd); + const result = spawnSync( + 'bash', + [ + '-c', + `set -Eeuo pipefail +maintenance_started=1 +stage_root=/tmp/unused +rollback() { printf 'rollback\\n'; } +cleanup() { printf 'cleanup\\n'; } +release_install_lock() { :; } +${handlers} +trap on_exit EXIT +trap 'exit 143' TERM +true +kill -TERM $$`, + ], + { encoding: 'utf8' } + ); + + expect(result.status).toBe(143); + expect(result.stdout).toContain('rollback'); + expect(result.stdout).toContain('cleanup'); + }); }); diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index fc6a61cf..c634d6f1 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -43,6 +43,10 @@ function createDeps( } ); }, + withInstallLifecycleLock: async ( + _backend: CLIProxyBackend, + operation: () => Promise + ) => operation(), }; return { deps, calls }; @@ -136,4 +140,55 @@ describe('installDashboardCliproxyVersion', () => { ); expect(calls.ensureCliproxyService).toBe(1); }); + + it('serializes concurrent dashboard stop-install-restore transactions', async () => { + let running = true; + let queue = Promise.resolve(); + let activeTransactions = 0; + let maxActiveTransactions = 0; + let restores = 0; + + const deps = { + getProxyStatus: () => ({ running }), + isCliproxyRunning: async () => running, + installCliproxyVersion: async () => { + running = false; + await new Promise((resolve) => setTimeout(resolve, 5)); + }, + ensureCliproxyService: async () => { + running = true; + restores += 1; + return { started: true, alreadyRunning: false, port: 8317 }; + }, + withInstallLifecycleLock: ( + _backend: CLIProxyBackend, + operation: () => Promise + ): Promise => { + const result = queue.then(async () => { + activeTransactions += 1; + maxActiveTransactions = Math.max(maxActiveTransactions, activeTransactions); + try { + return await operation(); + } finally { + activeTransactions -= 1; + } + }); + queue = result.then( + () => undefined, + () => undefined + ); + return result; + }, + }; + + const results = await Promise.all([ + installDashboardCliproxyVersion('6.7.1', 'plus', deps), + installDashboardCliproxyVersion('6.7.2', 'plus', deps), + ]); + + expect(maxActiveTransactions).toBe(1); + expect(restores).toBe(2); + expect(results.every((result) => result.restarted)).toBe(true); + expect(running).toBe(true); + }); }); From ee891bdd0edcdda0faa658cf6d28acd352b5c3c1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 22:05:29 -0400 Subject: [PATCH 5/7] docs(docker): document host continuity --- docker/README.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docker/README.md b/docker/README.md index 6c3ba275..ec581b3c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -79,6 +79,32 @@ The `ccs docker` flow uses the integrated assets in this directory: - `docker/supervisord.conf` - `docker/entrypoint-integrated.sh` +### Optional Host Continuity Timers + +For a persistent single-host stack, install the host reconciliation and safe-update scripts with +their systemd units: + +```bash +sudo install -d /opt/cliproxy +sudo install -m 0755 docker/host/ccs-cliproxy-{reconcile,safe-update}.sh /opt/cliproxy/ +sudo install -m 0644 docker/host/systemd/ccs-cliproxy-{reconcile,update}.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now ccs-cliproxy-reconcile.timer ccs-cliproxy-update.timer + +systemctl list-timers 'ccs-cliproxy-*' +journalctl -u ccs-cliproxy-reconcile.service -u ccs-cliproxy-update.service +``` + +By default, reconciliation expects `/opt/cliproxy/docker-compose.yml` with Compose project +`docker`. Keep those defaults when adopting an existing stack so its `docker_ccs_home` and +`docker_ccs_logs` volumes remain attached. The scripts support environment overrides for the +container, Compose paths and project, lock file, and logs. + +The updater downloads and verifies a replacement while the current proxy remains online, then +stops it only for the atomic swap and startup check. A failed update restores the previous binary +and version. Reconciliation probes the dashboard on port 3000 and CLIProxy on port 8317, then +escalates from supervised-process restart to container restart and finally Compose force-recreate. + ### Network Binding and Dashboard Auth The integrated Docker stack publishes the dashboard and CLIProxy ports on `127.0.0.1` by default. This keeps the services reachable from the Docker host and SSH tunnels without exposing them on every host interface. @@ -575,9 +601,11 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log ``` -### ETXTBSY Error on First Boot +### ETXTBSY During CLIProxy Update -On first container start, you may see `ETXTBSY: text file is busy` in dashboard logs. This is a known race condition where the dashboard tries to update the CLIProxy binary while it's already running. The dashboard recovers automatically on the next attempt. No action needed. +Current releases stage and verify CLIProxy updates before replacing the live binary atomically, so +`ETXTBSY: text file is busy` is not an expected first-boot condition. Upgrade older installations +that still report this error. ### Debug Mode From c8098532a6fba7411c89da1298207e5d48ffea98 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 22:05:39 -0400 Subject: [PATCH 6/7] chore(hardening): refresh source inventory --- docs/reports/hardening-inventory.json | 43 ++++++++++++++------------- docs/reports/hardening-inventory.md | 10 +++---- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index e810f90d..8ba94d63 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,10 +1,10 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2514, - "filesAffected": 262, - "hotpathOccurrences": 1192, - "hotpathFilesAffected": 155, + "totalOccurrences": 2519, + "filesAffected": 263, + "hotpathOccurrences": 1193, + "hotpathFilesAffected": 156, "topHotpathFiles": [ { "file": "src/management/shared-manager/diverged-file-adopter.ts", @@ -228,6 +228,22 @@ ], "markers": [] }, + { + "file": "src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts", + "count": 41, + "calls": [ + "existsSync", + "mkdirSync", + "mkdtempSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmdirSync", + "rmSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/management/shared-manager/diverged-file-adopter.ts", "count": 39, @@ -247,21 +263,6 @@ ], "markers": [] }, - { - "file": "src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts", - "count": 37, - "calls": [ - "existsSync", - "mkdirSync", - "mkdtempSync", - "readdirSync", - "readFileSync", - "renameSync", - "rmSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", "count": 36, @@ -578,8 +579,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 764, - "coverageRatio": 0.0851, + "totalSourceFiles": 765, + "coverageRatio": 0.085, "subdomainsWithZeroCreateLogger": [ "api", "bin", diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 90a6d9e1..dae7f9d4 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2514 | -| Sync fs files affected (all) | 262 | -| Sync fs occurrences (runtime hotpaths) | 1192 | -| Sync fs files affected (runtime hotpaths) | 155 | +| Sync fs occurrences (all) | 2519 | +| Sync fs files affected (all) | 263 | +| Sync fs occurrences (runtime hotpaths) | 1193 | +| Sync fs files affected (runtime hotpaths) | 156 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -60,7 +60,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | | hotpath console.error/warn occurrences | 264 (590 total, 326 CLI-UX exempt) | | hotpath console.error/warn files | 81 | -| files with createLogger | 65/764 | +| files with createLogger | 65/765 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | From 54c3e86e894fe6563a4e944709b1dae65bbf360b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 22:11:12 -0400 Subject: [PATCH 7/7] fix(docker): target live cliproxy compose stack --- docker/README.md | 2 +- docker/host/ccs-cliproxy-reconcile.sh | 10 ++++++---- .../docker/cliproxy-host-continuity-assets.test.ts | 5 ++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docker/README.md b/docker/README.md index ec581b3c..53e4a46d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -95,7 +95,7 @@ systemctl list-timers 'ccs-cliproxy-*' journalctl -u ccs-cliproxy-reconcile.service -u ccs-cliproxy-update.service ``` -By default, reconciliation expects `/opt/cliproxy/docker-compose.yml` with Compose project +By default, reconciliation expects `/root/.ccs/docker/docker-compose.integrated.yml` with Compose project `docker`. Keep those defaults when adopting an existing stack so its `docker_ccs_home` and `docker_ccs_logs` volumes remain attached. The scripts support environment overrides for the container, Compose paths and project, lock file, and logs. diff --git a/docker/host/ccs-cliproxy-reconcile.sh b/docker/host/ccs-cliproxy-reconcile.sh index ec67ca0b..074d2f90 100755 --- a/docker/host/ccs-cliproxy-reconcile.sh +++ b/docker/host/ccs-cliproxy-reconcile.sh @@ -3,8 +3,8 @@ set -Eeuo pipefail container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" -compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/opt/cliproxy}" -compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.yml}" +compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker}" +compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.integrated.yml}" compose_project="${CCS_CLIPROXY_COMPOSE_PROJECT:-docker}" lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" log_file="${CCS_CLIPROXY_RECONCILE_LOG:-/var/log/ccs-cliproxy-reconcile.log}" @@ -52,7 +52,9 @@ compose_recreate() { -f "$compose_file" up -d --force-recreate --build } -if ! docker inspect "$container_name" >/dev/null 2>&1; then +running_state="$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" + +if [ -z "$running_state" ]; then log "Container $container_name is missing; recreating from $compose_file" compose_up wait_for_health @@ -60,7 +62,7 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then exit 0 fi -if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != 'true' ]; then +if [ "$running_state" != 'true' ]; then log "Container $container_name is stopped; restoring the Compose service" compose_up wait_for_health diff --git a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts index d2111982..5d5f6fcc 100644 --- a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts +++ b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts @@ -86,10 +86,13 @@ release_install_lock`, }); it('recreates missing containers and escalates unhealthy recovery', () => { - expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_DIR:-/opt/cliproxy'); + expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker'); + expect(reconcileScript).toContain('$compose_dir/docker-compose.integrated.yml'); expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_PROJECT:-docker'); expect(reconcileScript).toContain('--project-name "$compose_project"'); expect(reconcileScript).toContain('-f "$compose_file" up -d --no-build'); + expect(reconcileScript).toContain("2>/dev/null || true"); + expect(reconcileScript).toContain('if [ -z "$running_state" ]'); expect(reconcileScript).toContain('restart ccs-dashboard cliproxy'); expect(reconcileScript).toContain('docker restart "$container_name"'); expect(reconcileScript).toContain('up -d --force-recreate --no-build');