From 7c2d02f76128fd83162d42564c084b985974b605 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Aug 2026 03:53:11 +0000 Subject: [PATCH 01/53] chore(release): 8.9.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 448ba081..878b2efe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0", + "version": "8.9.0-dev.1", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 51afccf5047ce44a95db07414e699c304bd82016 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Aug 2026 04:31:15 +0000 Subject: [PATCH 02/53] chore(release): 8.9.0-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 878b2efe..caee542d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.1", + "version": "8.9.0-dev.2", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 8e4def4713a9932c7b741e9bf791ee9904d4d695 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 10 Aug 2026 21:13:20 -0400 Subject: [PATCH 03/53] 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 04/53] 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 05/53] 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 06/53] 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 07/53] 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 08/53] 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 09/53] 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'); From a0990e0ef174c823c8814fcfe76c8ad03f0ca232 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 02:24:22 +0000 Subject: [PATCH 10/53] chore(release): 8.9.0-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index caee542d..4c2ffaaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.2", + "version": "8.9.0-dev.3", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From c30e9ca6179d584174622dce971b05e664ad98f7 Mon Sep 17 00:00:00 2001 From: "Khanh.NVM" Date: Thu, 13 Aug 2026 16:23:24 +0700 Subject: [PATCH 11/53] feat(cliproxy): add Grok 4.6 selection Co-Authored-By: Claude Fable 5 --- .../__tests__/model-catalog-compat.test.ts | 55 ++++++++++++++++--- src/cliproxy/__tests__/model-catalog.test.js | 13 +++++ src/cliproxy/model-catalog.ts | 12 ++++ src/cliproxy/services/catalog-cache.ts | 2 + ui/src/lib/model-catalogs.ts | 7 +++ .../unit/ui/lib/model-catalogs-xai.test.ts | 7 +++ .../unit/ui/lib/reasoning-control.test.ts | 7 +++ 7 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index aaaa01d6..e0c17565 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -46,21 +46,62 @@ describe('model-catalog compatibility lookups', () => { expect(PROVIDER_TO_CHANNEL.xai).toBe('xai'); }); - it('merges live xAI model metadata while preserving the static coding default', () => { + it('merges live Grok 4.6 metadata while preserving the static coding default', () => { const catalog = mergeCatalog('xai', [ { id: 'grok-build-0.1', display_name: 'Grok Build 0.1' }, { - id: 'grok-4.5', - display_name: 'Grok 4.5', - thinking: { levels: ['low', 'medium', 'high'] }, + id: 'grok-4.6', + display_name: 'Grok 4.6', + context_length: 500_000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh'], + zero_allowed: false, + }, }, ]); expect(catalog?.defaultModel).toBe('grok-build-0.1'); - expect(catalog?.models[1]?.thinking).toMatchObject({ - type: 'levels', - levels: ['low', 'medium', 'high'], + expect(catalog?.models[1]).toMatchObject({ + id: 'grok-4.6', + contextWindow: 500_000, + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + zeroAllowed: false, + }, }); + expect(catalog?.models[1]?.extendedContext).toBeUndefined(); + }); + + it('preserves static zeroAllowed when live level metadata omits it', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + thinking: { levels: ['none', 'low', 'medium', 'high'] }, + }, + ]); + + expect(catalog?.models[0]?.thinking).toMatchObject({ + type: 'levels', + levels: ['none', 'low', 'medium', 'high'], + zeroAllowed: true, + }); + }); + + it('prefers explicit live zeroAllowed over static level metadata', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + thinking: { + levels: ['low', 'medium', 'high'], + zero_allowed: false, + }, + }, + ]); + + expect(catalog?.models[0]?.thinking?.zeroAllowed).toBe(false); }); it('does not export extended-context capability from live xAI context length', () => { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index ca020e79..e8044d46 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -77,6 +77,7 @@ describe('Model Catalog', () => { const ids = MODEL_CATALOG.xai.models.map((model) => model.id); assert.deepStrictEqual(ids, [ 'grok-build-0.1', + 'grok-4.6', 'grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning', @@ -88,6 +89,18 @@ describe('Model Catalog', () => { ]); }); + it('includes Grok 4.6 with upstream reasoning metadata', () => { + const { MODEL_CATALOG } = modelCatalog; + const grok46 = MODEL_CATALOG.xai.models.find((model) => model.id === 'grok-4.6'); + + assert(grok46, 'Should include grok-4.6'); + assert.strictEqual(grok46.name, 'Grok 4.6'); + assert.strictEqual(grok46.contextWindow, 500000); + assert.strictEqual(grok46.thinking.type, 'levels'); + assert.deepStrictEqual(grok46.thinking.levels, ['low', 'medium', 'high', 'xhigh']); + assert.strictEqual(grok46.thinking.zeroAllowed, false); + }); + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 942730bb..ff2d632b 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -323,6 +323,18 @@ export const MODEL_CATALOG: Partial> = description: 'Fast coding model for agentic software engineering workflows', contextWindow: 256000, }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + description: + "SpaceXAI's smartest model built for long-running agents, interactive and visual work.", + contextWindow: 500000, + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + zeroAllowed: false, + }, + }, { id: 'grok-4.5', name: 'Grok 4.5', diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index bfbf0560..aafc4316 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -198,6 +198,7 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine return { type: 'levels', levels: remote.levels, + zeroAllowed: remote.zero_allowed, dynamicAllowed: remote.dynamic_allowed, }; } @@ -279,6 +280,7 @@ export function mergeCatalog( const mergedThinking = remoteEntry.thinking ? { ...remoteEntry.thinking, + zeroAllowed: remoteEntry.thinking.zeroAllowed ?? staticEntry.thinking?.zeroAllowed, maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel, } : staticEntry.thinking; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index b209a653..32c3d523 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -398,6 +398,13 @@ export const MODEL_CATALOGS: Record = { haiku: 'grok-composer-2.5-fast', }, }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + description: + "SpaceXAI's smartest model built for long-running agents, interactive and visual work.", + reasoningLevels: ['low', 'medium', 'high', 'xhigh'], + }, { id: 'grok-4.5', name: 'Grok 4.5', diff --git a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts index bb88a8c5..6568d504 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts @@ -11,6 +11,7 @@ describe('xAI model catalog defaults', () => { expect(catalog.defaultModel).toBe('grok-build-0.1'); expect(ids).toEqual([ 'grok-build-0.1', + 'grok-4.6', 'grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning', @@ -20,6 +21,12 @@ describe('xAI model catalog defaults', () => { 'grok-3-mini-fast', 'grok-composer-2.5-fast', ]); + expect(catalog.models.find((model) => model.id === 'grok-4.6')?.reasoningLevels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); expect(defaultModel?.presetMapping).toEqual({ default: 'grok-build-0.1', opus: 'grok-4.5', diff --git a/ui/tests/unit/ui/lib/reasoning-control.test.ts b/ui/tests/unit/ui/lib/reasoning-control.test.ts index a31f815f..a301070b 100644 --- a/ui/tests/unit/ui/lib/reasoning-control.test.ts +++ b/ui/tests/unit/ui/lib/reasoning-control.test.ts @@ -13,6 +13,11 @@ const xaiCatalog: ProviderCatalog = { defaultModel: 'grok-build-0.1', models: [ { id: 'grok-build-0.1', name: 'Grok Build 0.1' }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + reasoningLevels: ['low', 'medium', 'high', 'xhigh'], + }, { id: 'grok-4.5', name: 'Grok 4.5', reasoningLevels: ['low', 'medium', 'high'] }, { id: 'grok-4.3', name: 'Grok 4.3', reasoningLevels: ['none', 'low', 'medium', 'high'] }, ], @@ -96,6 +101,7 @@ describe('xai adapter', () => { const adapter = requireAdapter('xai'); it('returns per-model levels from catalog metadata', () => { + expect(adapter.getOptions('grok-4.6', xaiCatalog)).toEqual(['low', 'medium', 'high', 'xhigh']); expect(adapter.getOptions('grok-4.5', xaiCatalog)).toEqual(['low', 'medium', 'high']); expect(adapter.getOptions('grok-4.3', xaiCatalog)).toEqual(['none', 'low', 'medium', 'high']); }); @@ -107,6 +113,7 @@ describe('xai adapter', () => { }); it('writes and clears suffixed values through apply', () => { + expect(adapter.apply('grok-4.6', 'xhigh')).toBe('grok-4.6(xhigh)'); expect(adapter.apply('grok-4.5', 'high')).toBe('grok-4.5(high)'); expect(adapter.apply('grok-4.5(high)', undefined)).toBe('grok-4.5'); expect(adapter.apply('grok-4.3', 'none')).toBe('grok-4.3(none)'); From ea7906b97580533c158e09ff8777f7c66d248ac6 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub Date: Thu, 13 Aug 2026 22:29:17 +0800 Subject: [PATCH 12/53] feat(api): add OrcaRouter provider preset Add a named 'orcarouter' preset to the shared provider catalog mirroring the existing OpenRouter entry: OpenAI-compatible base URL https://api.orcarouter.ai/v1, default model openai/gpt-5.5, sk-orca-... key placeholder, and a dashboard icon. Regenerate the throw-error baseline for the line shift in provider-preset-catalog.ts. Co-Authored-By: Claude --- README.md | 4 +- eslint-rules/throw-error-baseline.json | 14 ++--- src/shared/provider-preset-catalog.ts | 17 ++++++ .../api/provider-presets-orcarouter.test.ts | 58 +++++++++++++++++++ ui/public/icons/orcarouter.svg | 1 + 5 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 tests/unit/api/provider-presets-orcarouter.test.ts create mode 100644 ui/public/icons/orcarouter.svg diff --git a/README.md b/README.md index 6fba155f..d4f724af 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ CCS gives you one stable command surface while letting you switch between: - multiple Claude subscriptions and isolated account contexts - OAuth providers like Codex, xAI/Grok, Kiro, Claude, Kimi, and more, with legacy Copilot compatibility for existing setups -- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, - Novita, Fireworks AI, and Alibaba Coding Plan +- API and local-model profiles like GLM, Kimi, OpenRouter, OrcaRouter, Ollama, + llama.cpp, Novita, Fireworks AI, and Alibaba Coding Plan Qwen Code account linking is not available in the bundled CLIProxy runtime yet; use an API-key Qwen profile such as Alibaba Coding Plan for Qwen models. diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 9fa25647..e1be244b 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -220,13 +220,13 @@ "src/shared/claude-extension-setup.ts:247", "src/shared/claude-extension-setup.ts:257", "src/shared/claude-extension-setup.ts:317", - "src/shared/provider-preset-catalog.ts:339", - "src/shared/provider-preset-catalog.ts:342", - "src/shared/provider-preset-catalog.ts:351", - "src/shared/provider-preset-catalog.ts:354", - "src/shared/provider-preset-catalog.ts:357", - "src/shared/provider-preset-catalog.ts:362", - "src/shared/provider-preset-catalog.ts:365", + "src/shared/provider-preset-catalog.ts:356", + "src/shared/provider-preset-catalog.ts:359", + "src/shared/provider-preset-catalog.ts:368", + "src/shared/provider-preset-catalog.ts:371", + "src/shared/provider-preset-catalog.ts:374", + "src/shared/provider-preset-catalog.ts:379", + "src/shared/provider-preset-catalog.ts:382", "src/shared/toml-object.ts:23", "src/targets/codex-adapter.ts:103", "src/targets/codex-adapter.ts:275", diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 5880588a..ab2db76d 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -10,6 +10,7 @@ export type ProviderPresetTarget = 'claude' | 'droid'; export const PROVIDER_PRESET_IDS = [ 'openrouter', + 'orcarouter', 'alibaba-coding-plan', 'huggingface', 'tuningengines', @@ -53,6 +54,7 @@ export interface ProviderPresetDefinition { } export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +export const ORCAROUTER_BASE_URL = 'https://api.orcarouter.ai/v1'; /** * Legacy aliases mapped to canonical preset IDs. @@ -84,6 +86,21 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/icons/openrouter.svg', }, + { + id: 'orcarouter', + name: 'OrcaRouter', + description: '188+ models from OpenAI, Anthropic, Google, Meta via auto-routing gateway', + baseUrl: ORCAROUTER_BASE_URL, + defaultProfileName: 'orcarouter', + defaultModel: 'openai/gpt-5.5', + apiKeyPlaceholder: 'sk-orca-...', + apiKeyHint: 'Get your API key at orcarouter.ai/console', + category: 'recommended', + requiresApiKey: true, + badge: '188+ models', + featured: true, + icon: '/icons/orcarouter.svg', + }, { id: 'alibaba-coding-plan', name: 'Alibaba Coding Plan', diff --git a/tests/unit/api/provider-presets-orcarouter.test.ts b/tests/unit/api/provider-presets-orcarouter.test.ts new file mode 100644 index 00000000..b4cac1a3 --- /dev/null +++ b/tests/unit/api/provider-presets-orcarouter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { PROVIDER_PRESET_IDS } from '../../../src/shared/provider-preset-catalog'; + +describe('provider-presets-orcarouter', () => { + it('resolves orcarouter preset id', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.id).toBe('orcarouter'); + expect(preset?.baseUrl).toBe('https://api.orcarouter.ai/v1'); + expect(preset?.defaultProfileName).toBe('orcarouter'); + }); + + it('registers orcarouter in PROVIDER_PRESET_IDS', () => { + expect(PROVIDER_PRESET_IDS).toContain('orcarouter'); + }); + + it('uses the OpenAI-compatible base URL with a /v1 suffix', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.baseUrl).toBe('https://api.orcarouter.ai/v1'); + expect(preset?.baseUrl.endsWith('/v1')).toBe(true); + }); + + it('pins a provider/model default (openai/gpt-5.5)', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.defaultModel).toBe('openai/gpt-5.5'); + }); + + it('validates orcarouter preset requires an API key with the sk-orca- placeholder', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.requiresApiKey).toBe(true); + expect(preset?.apiKeyPlaceholder).toBe('sk-orca-...'); + }); + + it('is a recommended, featured provider', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.category).toBe('recommended'); + expect(preset?.featured).toBe(true); + }); + + it('treats orcarouter as a valid preset id', () => { + expect(isValidPresetId('orcarouter')).toBe(true); + }); + + it('handles whitespace in orcarouter preset id', () => { + const preset = getPresetById(' orcarouter '); + expect(preset?.id).toBe('orcarouter'); + }); + + it('handles uppercase orcarouter preset id', () => { + const preset = getPresetById('ORCAROUTER'); + expect(preset?.id).toBe('orcarouter'); + }); + + it('does not resolve partial or invalid orcarouter ids', () => { + expect(getPresetById('orcarouter-invalid')).toBeUndefined(); + expect(isValidPresetId('orcarouter-invalid')).toBe(false); + }); +}); diff --git a/ui/public/icons/orcarouter.svg b/ui/public/icons/orcarouter.svg new file mode 100644 index 00000000..09c5ee0e --- /dev/null +++ b/ui/public/icons/orcarouter.svg @@ -0,0 +1 @@ +OrcaRouter \ No newline at end of file From 3e49834c9daa0a965316a1c26bbb9df7e0b09908 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 14 Aug 2026 17:57:39 -0400 Subject: [PATCH 13/53] fix(delegation): flush stdout before exit to prevent piped output truncation console.log followed immediately by process.exit truncated piped output at the pipe buffer (about 64 KiB) because stdout writes to pipes are asynchronous. Interactive TTY runs were unaffected, so the bug only surfaced for callers consuming ccs delegation output through a pipe, such as scripts parsing --output-format json results from thinking heavy GLM sessions. Await an explicit stdout write callback before exiting in both route() and _handleContinue(). --- src/delegation/delegation-handler.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index df9f4c6a..58994328 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -35,6 +35,17 @@ function isFlag(arg: string): boolean { return arg.startsWith('-'); } +/** + * Write to stdout and wait for the flush to complete. + * process.exit() right after a large piped write truncates output at the + * pipe buffer (~64 KiB), so delegation results must be drained first. + */ +function writeStdoutAndFlush(text: string): Promise { + return new Promise((resolve) => { + process.stdout.write(text + '\n', () => resolve()); + }); +} + function isInlineCcsValueFlag(arg: string): boolean { return Array.from(CCS_FLAGS_WITH_VALUE).some( (flag) => flag.startsWith('--') && arg.startsWith(`${flag}=`) @@ -147,7 +158,7 @@ export class DelegationHandler { // 5. Format and display results const formatted = await ResultFormatter.format(result); - console.log(formatted); + await writeStdoutAndFlush(formatted); // 6. Exit with proper code process.exit(result.exitCode || 0); @@ -192,7 +203,7 @@ export class DelegationHandler { }); const formatted = await ResultFormatter.format(result); - console.log(formatted); + await writeStdoutAndFlush(formatted); process.exit(result.exitCode || 0); } From 6e3a16b84c7eb50357da426f350b7711cb4694cd Mon Sep 17 00:00:00 2001 From: OctoBored <212877535+OctoBored@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:37:34 +0000 Subject: [PATCH 14/53] fix(README): update star history chart to use working domain The current star history chart is broken due to GitHub stargazer API restrictions. Switch to star-history.dera.page which uses an alternative data source and requires no API token. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fba155f..04bee1d8 100644 --- a/README.md +++ b/README.md @@ -228,4 +228,4 @@ ccs ollama "summarize these logs" ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://www.star-history.com/#kaitranntt/ccs&type=date&legend=top-left) +[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left) From fe3447f4871c4b53c2bf0f2f9e9cba9d057002bf Mon Sep 17 00:00:00 2001 From: Aaron Vu Date: Mon, 17 Aug 2026 15:27:02 +0700 Subject: [PATCH 15/53] fix(image-analysis): honor configured profile_backends at launch resolveImageAnalysisRuntimeStatus defaulted to DEFAULT_IMAGE_ANALYSIS_CONFIG when callers omitted the config argument. That constant ships empty profile_backends and a gemini fallback_backend, so the launch paths that call it without a config (settings profile dispatch and headless delegation) never saw user-configured mappings. A profile mapped to another backend still resolved to gemini, failed the Gemini auth check, and silently dropped to native Read. Profiles whose model has no vision support could not read images at all, even with a reachable CLIProxy and an authenticated backend. getImageAnalysisHookEnv already reads the saved config, so the launch env and the runtime status disagreed on the same launch: CCS_IMAGE_ANALYSIS_BACKEND_ID carried the mapped backend while the status object reported native-read. Default to getImageAnalysisConfig() so both read the same source. Callers that pass an explicit config keep their existing behavior. Co-Authored-By: Claude --- .../hooks/image-analysis-runtime-status.ts | 15 ++- ...runtime-status-circular-dependency.test.ts | 11 +- ...ysis-runtime-status-config-default.test.ts | 112 ++++++++++++++++++ 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index c68e429a..b875559e 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -16,10 +16,8 @@ import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy/proxy-tar import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { - DEFAULT_IMAGE_ANALYSIS_CONFIG, - type ImageAnalysisConfig, -} from '../../config/unified-config-types'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; +import { type ImageAnalysisConfig } from '../../config/unified-config-types'; import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, @@ -191,9 +189,14 @@ export async function hydrateImageAnalysisRuntimeStatus( export async function resolveImageAnalysisRuntimeStatus( context: ImageAnalysisResolutionContext, - config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG, + config?: ImageAnalysisConfig, deps: Partial = {} ): Promise { - const baseStatus = resolveImageAnalysisStatus(context, config); + // Fall back to the user's saved image_analysis config, not the built-in + // constant. Launch paths call this without an explicit config, and the + // constant carries empty profile_backends plus a gemini fallback_backend, + // so user mappings were dropped and every profile resolved to gemini. + const resolvedConfig = config ?? getImageAnalysisConfig(); + const baseStatus = resolveImageAnalysisStatus(context, resolvedConfig); return hydrateImageAnalysisRuntimeStatus(baseStatus, deps); } diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts index d6060612..e4fb1a75 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts @@ -89,8 +89,15 @@ describe('image-analysis-runtime-status circular dependency regression', () => { '../../cliproxy/services/stats-fetcher': { isCliproxyRunning: async () => true, }, - '../../config/unified-config-types': { - DEFAULT_IMAGE_ANALYSIS_CONFIG: {}, + '../../config/unified-config-types': {}, + '../../config/config-loader-facade': { + getImageAnalysisConfig: () => ({ + enabled: true, + timeout: 60, + provider_models: { ghcp: 'claude-haiku-4.5' }, + fallback_backend: 'ghcp', + profile_backends: {}, + }), }, './image-analysis-backend-resolver': { resolveImageAnalysisStatus: () => createStatus(), diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts new file mode 100644 index 00000000..3f859652 --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Regression: resolveImageAnalysisRuntimeStatus used to default to the built-in + * DEFAULT_IMAGE_ANALYSIS_CONFIG constant, which carries empty profile_backends + * and a gemini fallback_backend. Launch paths call it without an explicit + * config, so user-configured profile_backends were dropped and every settings + * profile resolved to gemini, then bailed to native Read on missing Gemini auth. + */ +describe('resolveImageAnalysisRuntimeStatus config default', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-config-default-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' claude: claude-haiku-4-5-20251001', + ' fallback_backend: claude', + ' profile_backends:', + ' deepseek: claude', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('honors user profile_backends when no explicit config is passed', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'claude', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'claude', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('claude'); + expect(status.resolutionSource).toBe('profile-backend'); + expect(status.model).toBe('claude-haiku-4-5-20251001'); + expect(status.runtimePath).toBe('/api/provider/claude'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); +}); From b9190a6e57708f576a5c322f3d12e470eb9d6b36 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 19 Aug 2026 16:35:27 -0400 Subject: [PATCH 16/53] docs: refresh hardening inventory counts --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 8ba94d63..fe9f5bd1 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -720,7 +720,7 @@ ] }, "largeFiles": { - "countOver400": 91, + "countOver400": 92, "countOver600": 42, "topOver400": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index dae7f9d4..69071af5 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -62,7 +62,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | hotpath console.error/warn files | 81 | | 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 > 400 LOC | 92 | | files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files From afa663b5b2f999b19bef14a208044e02e8b3c122 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 19 Aug 2026 16:43:15 -0400 Subject: [PATCH 17/53] fix(image-analysis): route original backend at CLIProxy root Fixes #1703 --- .../hooks/image-analysis-backend-resolver.ts | 7 ++ ...ysis-runtime-status-config-default.test.ts | 4 +- ...time-status-original-backend-route.test.ts | 111 ++++++++++++++++++ ...-runtime-status-plus-backend-route.test.ts | 110 +++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts create mode 100644 tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index 229d6e11..8bef4ad6 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -8,6 +8,7 @@ import { mapExternalProviderName, } from '../../cliproxy/provider-capabilities'; import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog'; +import { buildCliproxyProviderPath } from '../../cliproxy/config/provider-route'; import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer'; import type { CliproxyBridgeMetadata } from '../../api/services/profile-types'; import type { Settings } from '../../types/config'; @@ -251,6 +252,12 @@ function getRuntimePath(backendId: string | null): string | null { return null; } + if (isCLIProxyProvider(backendId)) { + // Claude-compatible traffic and the original backend route at the + // CLIProxy root; /api/provider/ scoped routes are Plus-only. + return buildCliproxyProviderPath(backendId) || null; + } + return `/api/provider/${backendId}`; } diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts index 3f859652..e3909c8f 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts @@ -106,7 +106,9 @@ describe('resolveImageAnalysisRuntimeStatus config default', () => { expect(status.backendId).toBe('claude'); expect(status.resolutionSource).toBe('profile-backend'); expect(status.model).toBe('claude-haiku-4-5-20251001'); - expect(status.runtimePath).toBe('/api/provider/claude'); + // Claude-compatible traffic routes at the CLIProxy root; the scoped + // /api/provider/claude path is not part of the canonical route helper. + expect(status.runtimePath).toBeNull(); expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); }); }); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts new file mode 100644 index 00000000..4e9a63bc --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Regression (#1703): the original CLIProxy backend only serves the root + * /v1/messages route. Image analysis used to build + * /api/provider//v1/messages unconditionally, which 404s on original. + * The default backend is original, so the default-config path must resolve + * a null runtimePath (root route) for CLIProxy providers. + */ +describe('resolveImageAnalysisRuntimeStatus original backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-original-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('resolves the root runtime path for the original backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + // Root route: the hook appends /v1/messages directly to the proxy base. + expect(status.runtimePath).toBeNull(); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts new file mode 100644 index 00000000..6d23d83b --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Counterpart to the original-backend route regression (#1703): with + * cliproxy.backend: plus, non-Claude providers keep their scoped + * /api/provider/ routes for image analysis. + */ +describe('resolveImageAnalysisRuntimeStatus plus backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-plus-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'cliproxy:', + ' backend: plus', + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('keeps the scoped provider route for the plus backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + expect(status.runtimePath).toBe('/api/provider/gemini'); + }); +}); From c43cd6caf5002514d8c9d7e3ff7da2a2ce61253c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 19 Aug 2026 16:43:15 -0400 Subject: [PATCH 18/53] fix(update): detect pnpm v9+ global store layouts Fixes #1706 --- src/utils/package-manager-detector.ts | 10 ++++- .../utils/package-manager-detector.test.ts | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index d5f4a051..347f5d7c 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -125,9 +125,15 @@ function inferInstallFromPath( normalizedPath.includes('/global/') && normalizedPath.includes('/node_modules/@kaitranntt/ccs') ) { - const pnpmFlatMatch = normalizedPath.match(/\/global\/([^/]+)\/node_modules\/@kaitranntt\/ccs/); + // pnpm v9+ nests a version segment plus a store-hash segment under + // global/ (e.g. global/v11/139e7-1a00df73ffb.../node_modules), while + // older pnpm uses a single segment (global/5/node_modules). Accept one + // or more segments but keep excluding yarn's global/lib/node_modules. + const pnpmFlatMatch = normalizedPath.match( + /\/global\/([^/]+(?:\/[^/]+)*)\/node_modules\/@kaitranntt\/ccs/ + ); - if (!pnpmFlatMatch || pnpmFlatMatch[1] === 'lib') { + if (!pnpmFlatMatch || pnpmFlatMatch[1].split('/')[0] === 'lib') { return null; } diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts index 5bb1164a..d297c015 100644 --- a/tests/unit/utils/package-manager-detector.test.ts +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -186,6 +186,45 @@ describe('package-manager-detector', () => { expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); }); + it('detects pnpm v9+ global store layouts with version and hash segments', () => { + const tempRoot = makeTempDir('ccs-install-detector-pnpm-global-v11-'); + const packageRoot = join( + tempRoot, + 'Library', + 'pnpm', + 'global', + 'v11', + '139e7-1a00df73ffb-064d180929716eff', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'Library', 'pnpm')); + }); + + it('detects Windows pnpm v10 global store layouts with nested segments', () => { + const install = detectCurrentInstall( + 'C:/Users/dev/AppData/Local/pnpm/global/v10/b3b1c2de-9f88-4a7e/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe('C:/Users/dev/AppData/Local/pnpm'); + }); + + it('still excludes yarn global/lib/node_modules from pnpm detection', () => { + const install = detectCurrentInstall( + '/Users/dev/.yarn/global/lib/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).not.toBe('pnpm'); + }); + it('detects Windows npm globals without a lib directory', () => { const install = detectCurrentInstall( 'C:/Program Files/node-prefix/node_modules/@kaitranntt/ccs/dist/ccs.js' From ae1559aeb01d466787026bdabff0c24d58895bd0 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:14:14 -0400 Subject: [PATCH 19/53] fix(ci): publish dev releases with public access for scoped package (#1715) --- package.json | 3 +++ scripts/dev-release.sh | 2 +- .../unit/scripts/github/dev-release-workflow.test.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c2ffaaf..ffa37584 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "url": "git+https://github.com/kaitranntt/ccs.git" }, "license": "MIT", + "publishConfig": { + "access": "public" + }, "author": { "name": "Tam Nhu Tran (Kai)", "email": "kaitranntt@users.noreply.github.com" diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh index de01bde0..fd5607e8 100755 --- a/scripts/dev-release.sh +++ b/scripts/dev-release.sh @@ -115,7 +115,7 @@ PACKAGE_NAME=$(jq -r '.name' package.json) if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then log_info "npm already has ${PACKAGE_NAME}@${VERSION}, skipping publish" else - npm publish --tag dev + npm publish --tag dev --access public log_info "Published to npm with @dev tag" fi diff --git a/tests/unit/scripts/github/dev-release-workflow.test.ts b/tests/unit/scripts/github/dev-release-workflow.test.ts index b61d79ff..5a2d1d40 100644 --- a/tests/unit/scripts/github/dev-release-workflow.test.ts +++ b/tests/unit/scripts/github/dev-release-workflow.test.ts @@ -60,4 +60,16 @@ describe('dev release workflow', () => { expect(script).toContain('git commit -m "chore(release): ${VERSION}"'); expect(script).not.toContain('git commit -m "chore(release): ${VERSION} [skip ci]"'); }); + + test('publishes dev releases with explicit public access for scoped package', () => { + const packageJsonPath = resolvePath('../../../../package.json'); + const scriptPath = resolvePath('../../../../scripts/dev-release.sh'); + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const script = fs.readFileSync(scriptPath, 'utf8'); + + expect(packageJson.name).toBe('@kaitranntt/ccs'); + expect(packageJson.publishConfig).toEqual({ access: 'public' }); + expect(script).toContain('npm publish --tag dev --access public'); + }); }); From a53980782f0c2188f339e498b455d3cd1e456d96 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 15:33:50 +0000 Subject: [PATCH 20/53] chore(release): 8.9.0-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ffa37584..33101af6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.3", + "version": "8.9.0-dev.4", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From fde7bd83db96ce4fa87cb3b5403a7ab51c4ccde9 Mon Sep 17 00:00:00 2001 From: mardausdennis <71312763+mardausdennis@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:15:41 +0200 Subject: [PATCH 21/53] feat(dashboard): collapse the CLIProxy status widget ProxyStatusWidget sits outside the sidebar ScrollArea, so every row it renders is taken from the provider list. With the proxy running and pool routing on it renders ten stacked blocks, and the only Collapsible covers version management, which is already closed in that state. Add a chevron that collapses the whole widget in local and remote mode, keeping the status row, version row and action buttons visible. The preference is stored in localStorage and defaults to expanded, so existing setups are unchanged. Opening version settings from a collapsed widget expands it again. --- .../monitoring/proxy-status-widget.tsx | 179 +++++++++++++----- ui/src/lib/i18n.ts | 12 ++ .../monitoring/proxy-status-widget.test.tsx | 97 ++++++++++ 3 files changed, 237 insertions(+), 51 deletions(-) create mode 100644 ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index cfa026df..c61ca5ee 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -27,6 +27,8 @@ import { FileDown, Check, AlertCircle, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -75,6 +77,26 @@ import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card type PendingInstallRisk = 'faulty' | 'experimental'; +const WIDGET_COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +function readCollapsedPreference(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(WIDGET_COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function persistCollapsedPreference(collapsed: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(WIDGET_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Ignore storage errors (private mode, quota, etc.) + } +} + function formatUptime(startedAt?: string): string { if (!startedAt) return ''; const start = new Date(startedAt).getTime(); @@ -135,6 +157,7 @@ function IconButton({ )} onClick={onClick} disabled={disabled} + aria-label={tooltip} > {isPending ? ( @@ -182,6 +205,31 @@ export function ProxyStatusWidget() { const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); + // Whole-widget collapse so the sidebar provider list keeps the vertical space + const [isCollapsed, setIsCollapsed] = useState(readCollapsedPreference); + + const handleToggleCollapsed = () => { + const next = !isCollapsed; + setIsCollapsed(next); + // Collapsing also closes version management so it cannot stay open but hidden + if (next) setIsExpanded(false); + persistCollapsedPreference(next); + }; + + // Opening version settings from a collapsed widget must reveal the section + const handleToggleVersionSettings = () => { + const next = !isExpanded; + setIsExpanded(next); + if (next && isCollapsed) { + setIsCollapsed(false); + persistCollapsedPreference(false); + } + }; + + const collapseTooltip = isCollapsed + ? t('proxyStatusWidget.tooltipExpandWidget') + : t('proxyStatusWidget.tooltipCollapseWidget'); + // Confirmation dialog state for risky versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [pendingInstallVersion, setPendingInstallVersion] = useState(null); @@ -325,30 +373,50 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.active')} - - - -
-
- {remoteDisplayHost} +
+ +
-

- {t('proxyStatusWidget.trafficAutoRouted')} -

- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + {!isCollapsed && ( + <> +
+
+ {remoteDisplayHost} +
+

+ {t('proxyStatusWidget.trafficAutoRouted')} +

+
+ + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )}
); } @@ -413,9 +481,14 @@ export function ProxyStatusWidget() { ? t('proxyStatusWidget.tooltipClose') : t('proxyStatusWidget.tooltipVersionSettings') } - onClick={() => setIsExpanded(!isExpanded)} + onClick={handleToggleVersionSettings} className={isExpanded ? 'bg-muted' : undefined} /> + @@ -458,7 +531,7 @@ export function ProxyStatusWidget() { )} {/* Stats row when running */} - {isRunning && status && ( + {!isCollapsed && isRunning && status && (
{t('proxyStatusWidget.port', { port: status.port })} @@ -478,35 +551,39 @@ export function ProxyStatusWidget() {
)} - {/* Sync status row */} -
- {isSyncConfigured ? ( - - ) : ( - - )} - - {syncStatusText} - -
+ {!isCollapsed && ( + <> + {/* Sync status row */} +
+ {isSyncConfigured ? ( + + ) : ( + + )} + + {syncStatusText} + +
- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )} {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6ac2da8a..d8bdc792 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -428,6 +428,8 @@ const resources = { tooltipStop: 'Stop', tooltipClose: 'Close', tooltipVersionSettings: 'Version settings', + tooltipCollapseWidget: 'Collapse', + tooltipExpandWidget: 'Expand', versionManagement: 'Version Management', selectVersionPlaceholder: 'Select version to install...', stable: '(stable)', @@ -3180,6 +3182,8 @@ const resources = { tooltipStop: 'Parar', tooltipClose: 'Fechar', tooltipVersionSettings: 'Configurações de versão', + tooltipCollapseWidget: 'Recolher', + tooltipExpandWidget: 'Expandir', versionManagement: 'Gerenciamento de Versão', selectVersionPlaceholder: 'Selecione a versão a instalar...', stable: '(estável)', @@ -5962,6 +5966,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '关闭', tooltipVersionSettings: '版本设置', + tooltipCollapseWidget: '收起', + tooltipExpandWidget: '展开', versionManagement: '版本管理', selectVersionPlaceholder: '选择要安装的版本...', stable: '(稳定版)', @@ -8595,6 +8601,8 @@ const resources = { tooltipStop: 'Dừng lại', tooltipClose: 'Đóng', tooltipVersionSettings: 'Cài đặt phiên bản', + tooltipCollapseWidget: 'Thu gọn', + tooltipExpandWidget: 'Mở rộng', versionManagement: 'Quản lý phiên bản', selectVersionPlaceholder: 'Chọn phiên bản để cài đặt...', stable: '(ổn định)', @@ -11329,6 +11337,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '閉じる', tooltipVersionSettings: 'バージョン設定', + tooltipCollapseWidget: '折りたたむ', + tooltipExpandWidget: '展開', versionManagement: 'バージョン管理', selectVersionPlaceholder: 'インストールするバージョンを選択...', stable: '(安定版)', @@ -14087,6 +14097,8 @@ const resources = { tooltipStop: '중지', tooltipClose: '닫기', tooltipVersionSettings: '버전 설정', + tooltipCollapseWidget: '접기', + tooltipExpandWidget: '펼치기', versionManagement: '버전 관리', selectVersionPlaceholder: '설치할 버전 선택...', stable: '(안정)', diff --git a/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx new file mode 100644 index 00000000..55893a05 --- /dev/null +++ b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; + +const COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +const mocks = vi.hoisted(() => ({ + noopMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useProxyStatus: () => ({ + data: { running: true, port: 8317, sessionCount: 1 }, + isLoading: false, + }), + useCliproxyUpdateCheck: () => ({ data: { backendLabel: 'CLIProxy', currentVersion: '7.2.137' } }), + useCliproxyVersions: () => ({ data: undefined, isLoading: false }), + useCliproxyRoutingStrategy: () => ({ data: undefined, isLoading: false, error: null }), + useCliproxySessionAffinity: () => ({ data: undefined, isLoading: false, error: null }), + useUpdateCliproxyRoutingStrategy: mocks.noopMutation, + useUpdateCliproxySessionAffinity: mocks.noopMutation, + useStartProxy: mocks.noopMutation, + useStopProxy: mocks.noopMutation, + useRestartProxy: mocks.noopMutation, + useInstallVersion: mocks.noopMutation, +})); + +vi.mock('@/hooks/use-cliproxy-sync', () => ({ + useSyncStatus: () => ({ data: { configured: true } }), + useExecuteSync: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/lib/api-client', () => ({ + api: { + cliproxyServer: { + get: () => Promise.resolve({ remote: { enabled: false, host: '' } }), + }, + }, +})); + +vi.mock('@/components/cliproxy/routing-guidance-card', () => ({ + RoutingGuidanceCard: () =>
, +})); + +import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; + +describe('ProxyStatusWidget collapse', () => { + beforeEach(() => { + vi.mocked(window.localStorage.getItem).mockReturnValue(null); + vi.mocked(window.localStorage.setItem).mockClear(); + }); + + it('shows the routing and status detail by default', () => { + render(); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + }); + + it('hides the detail and persists the preference when collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'true'); + }); + + it('keeps the status row and version visible while collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.getByText('CLIProxy')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('starts collapsed when the stored preference says so', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('reveals the detail again when version settings are opened while collapsed', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Version settings' })); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'false'); + }); +}); From 00a4dceb94a5a21d740e7e7d057c6a1b7398d8ee Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 23 Aug 2026 08:32:26 +0200 Subject: [PATCH 22/53] fix(shared-manager): publish adopted settings by replacement Adoption moved the canonical settings.json aside with rename() and left the path empty until publication, roughly 100 ms later. Claude Code or a second `ccs` starting inside that window found no file and seeded an empty placeholder; publication then failed with EEXIST because link() is no-replace, and the rollback published a backup and unlinked the claim, destroying the only remaining copy of the user's settings. Recovering meant digging through sidecar files by hand. Publish by replacement instead: write a temp file next to the canonical inode and rename() it over the target, so the path always holds a regular file and no placeholder can be seeded. A compare-and-swap guard on (ino, mtime, size) runs immediately before the rename and refuses to publish when the canonical inode changed since it was read, so a writer that got there first is still never clobbered. The pre-image backup is published before the replacement, keeping the old content recoverable if publication is interrupted. Drops the canonical claim entirely along with restoreCanonicalClaim, and folds the two identical sidecar publishers into one helper. recoverOrphanedCanonicalClaim stays, since claims written by older versions may still be on disk. New tests cover both writers seen in the incident: Claude Code seeding `{}` with a trailing newline, and a second `ccs` seeding the 2-byte variant from shared-dir-linker. Four tests that pinned the claim-based design were rewritten, among them `preserves a canonical write that lands during no-replace publication`, whose intent is now enforced by the CAS guard instead of by an EEXIST from a no-replace link. Built [OnSteroids](https://onsteroids.ai) --- docs/reports/hardening-inventory.json | 42 ++-- docs/reports/hardening-inventory.md | 8 +- .../shared-manager/diverged-file-adopter.ts | 180 +++++++++++------ tests/unit/shared-manager.test.ts | 191 +++++++++++++++--- 4 files changed, 304 insertions(+), 117 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index fe9f5bd1..1f1a0273 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,15 +1,16 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2519, + "totalOccurrences": 2516, "filesAffected": 263, - "hotpathOccurrences": 1193, + "hotpathOccurrences": 1190, "hotpathFilesAffected": 156, "topHotpathFiles": [ { "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, + "count": 36, "calls": [ + "chmodSync", "closeSync", "fsyncSync", "linkSync", @@ -245,9 +246,24 @@ "markers": [] }, { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, + "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", + "count": 36, "calls": [ + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "rmSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 36, + "calls": [ + "chmodSync", "closeSync", "fsyncSync", "linkSync", @@ -263,20 +279,6 @@ ], "markers": [] }, - { - "file": "src/cliproxy/executor/__tests__/variant-port-integration.test.js", - "count": 36, - "calls": [ - "existsSync", - "mkdirSync", - "readdirSync", - "readFileSync", - "rmSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/cliproxy/executor/__tests__/composite-variant-service.test.ts", "count": 33, @@ -720,7 +722,7 @@ ] }, "largeFiles": { - "countOver400": 92, + "countOver400": 93, "countOver600": 42, "topOver400": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 69071af5..43e4b23c 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) | 2519 | +| Sync fs occurrences (all) | 2516 | | Sync fs files affected (all) | 263 | -| Sync fs occurrences (runtime hotpaths) | 1193 | +| Sync fs occurrences (runtime hotpaths) | 1190 | | Sync fs files affected (runtime hotpaths) | 156 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -17,7 +17,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Sync Calls | API Names | |---|---:|---| -| `src/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/management/shared-manager/diverged-file-adopter.ts` | 36 | chmodSync, closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | @@ -62,7 +62,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | hotpath console.error/warn files | 81 | | 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 | 92 | +| files > 400 LOC | 93 | | files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 43829a8f..1ac67e28 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -137,7 +137,42 @@ function validateManagedJson(filePath: string, content: Buffer): boolean { } } -function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void { +/** + * Identity of the canonical inode as it was read. Publication compares it + * again immediately before replacing the file, so a concurrent writer is + * detected instead of silently overwritten. + */ +interface CanonicalIdentity { + ino: number; + mtimeMs: number; + size: number; +} + +function readCanonicalIdentity(writePath: string): CanonicalIdentity | null { + const stats = getLstatSync(writePath); + if (!stats?.isFile()) return null; + return { ino: stats.ino, mtimeMs: stats.mtimeMs, size: stats.size }; +} + +function canonicalIdentityMatches( + expected: CanonicalIdentity | null, + current: CanonicalIdentity | null +): boolean { + if (!expected || !current) return expected === current; + return ( + expected.ino === current.ino && + expected.mtimeMs === current.mtimeMs && + expected.size === current.size + ); +} + +/** + * Create a file only if the path is free, writing the content atomically. + * + * link() is an atomic no-replace operation, so concurrent CCS processes + * cannot overwrite each other's sidecar artifacts. + */ +function createFileNoReplace(targetPath: string, content: Buffer, mode: number): void { const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; let descriptor: number | null = null; try { @@ -162,16 +197,17 @@ function atomicWriteFile(targetPath: string, content: Buffer, mode: number): voi } } -function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string { - const basePath = `${canonicalPath}.bak-ccs-adopt`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; +/** + * Publish a sidecar next to a managed file, never replacing an existing one. + * Numbered suffixes keep every concurrent writer's artifact recoverable. + */ +function publishSidecarNoReplace(basePath: string, content: Buffer, mode: number): string { let sequence = 0; while (true) { - const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + const sidecarPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; try { - atomicWriteFile(backupPath, content, mode); - return backupPath; + createFileNoReplace(sidecarPath, content, mode); + return sidecarPath; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; sequence++; @@ -179,43 +215,74 @@ function publishBackupNoReplace(sourcePath: string, canonicalPath: string): stri } } -function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string { - const basePath = `${divergedPath}.ccs-adopted-recovery`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; - let sequence = 0; - while (true) { - const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; - try { - atomicWriteFile(recoveryPath, content, mode); - return recoveryPath; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - sequence++; - } - } -} - -function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void { +/** + * Publish adopted content onto the canonical path by replacement. + * + * The canonical path is never emptied: a fully written temp file is renamed + * over it, so no window exists in which Claude Code or a second `ccs` can + * observe the path as missing and seed an empty placeholder there. + * + * A compare-and-swap guard runs as late as possible - after the temp file is + * durable, immediately before the rename. When the canonical inode changed + * since it was read, publication is refused rather than clobbering a writer + * that got there first; the adopted bytes stay in the sidecars the caller + * published. A pure chmod is not a content change, so the mode the inode + * carries at publication time wins. + */ +function publishCanonicalContent( + writePath: string, + content: Buffer, + mode: number, + expected: CanonicalIdentity | null +): void { + const tempPath = `${writePath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; + let descriptor: number | null = null; try { - fs.linkSync(claimPath, writePath); - fs.unlinkSync(claimPath); + descriptor = fs.openSync(tempPath, 'wx', mode); + fs.fchmodSync(descriptor, mode); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + + const currentStats = getLstatSync(writePath); + const current = currentStats?.isFile() + ? { ino: currentStats.ino, mtimeMs: currentStats.mtimeMs, size: currentStats.size } + : null; + if (!canonicalIdentityMatches(expected, current)) { + throw Object.assign(new TypeError(`Canonical file changed during adoption: ${writePath}`), { + code: 'EEXIST', + }); + } + + const publishMode = currentStats ? currentStats.mode & 0o777 : mode; + if (publishMode !== mode) { + fs.chmodSync(tempPath, publishMode); + } + fs.renameSync(tempPath, writePath); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - publishBackupNoReplace(claimPath, canonicalPath); - fs.unlinkSync(claimPath); + if (descriptor !== null) { + fs.closeSync(descriptor); + } + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } + throw err; } } function getCanonicalFile(canonicalPath: string): { content: Buffer | null; + identity: CanonicalIdentity | null; mode: number; mtimeMs: number | null; writePath: string; } { const canonicalLstat = getLstatSync(canonicalPath); if (!canonicalLstat) { - return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; + return { content: null, identity: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; } let writePath = canonicalPath; @@ -230,8 +297,12 @@ function getCanonicalFile(canonicalPath: string): { }); } + // Identity first: a write landing between the two reads leaves us holding + // newer bytes than the identity describes, and publication fails closed. + const identity = readCanonicalIdentity(writePath); return { - content: fs.readFileSync(canonicalPath), + content: fs.readFileSync(writePath), + identity, mode: canonicalStats.mode & 0o777, mtimeMs: canonicalStats.mtimeMs, writePath, @@ -260,8 +331,6 @@ export function adoptDivergedFileContent( throw err; } - let canonicalClaimPath: string | null = null; - let canonicalWritePath: string | null = null; let divergencePreserved = false; try { const claimedStats = fs.lstatSync(claimPath); @@ -288,24 +357,12 @@ export function adoptDivergedFileContent( } const canonical = getCanonicalFile(canonicalPath); - let current = canonical.content; - let publishMode = canonical.mode; - if (current) { - canonicalWritePath = canonical.writePath; - canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; - fs.renameSync(canonical.writePath, canonicalClaimPath); - const currentStats = fs.statSync(canonicalClaimPath); - publishMode = currentStats.mode & 0o777; - current = fs.readFileSync(canonicalClaimPath); - if (diverged.equals(current)) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (canonical.content) { + if (diverged.equals(canonical.content)) { fs.unlinkSync(claimPath); return 'claimed'; } - if (claimedStats.mtimeMs <= currentStats.mtimeMs) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (claimedStats.mtimeMs <= (canonical.mtimeMs ?? 0)) { preserveClaim( claimPath, divergedPath, @@ -313,14 +370,17 @@ export function adoptDivergedFileContent( ); return 'claimed'; } + // Publish the pre-image before the canonical file is replaced, so an + // interruption mid-publication still leaves the old content recoverable. + publishSidecarNoReplace(`${canonicalPath}.bak-ccs-adopt`, canonical.content, canonical.mode); } + publishSidecarNoReplace( + `${divergedPath}.ccs-adopted-recovery`, + diverged, + claimedStats.mode & 0o777 + ); - if (canonicalClaimPath) { - publishBackupNoReplace(canonicalClaimPath, canonicalPath); - } - publishAdoptedRecoveryNoReplace(claimPath, divergedPath); - - atomicWriteFile(canonical.writePath, diverged, publishMode); + publishCanonicalContent(canonical.writePath, diverged, canonical.mode, canonical.identity); if (!fs.readFileSync(canonicalPath).equals(diverged)) { throw Object.assign( new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`), @@ -329,10 +389,6 @@ export function adoptDivergedFileContent( } ); } - if (canonicalClaimPath) { - fs.unlinkSync(canonicalClaimPath); - canonicalClaimPath = null; - } if (getLstatSync(divergedPath)) { preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); divergencePreserved = true; @@ -346,10 +402,6 @@ export function adoptDivergedFileContent( ); return 'claimed'; } catch (err) { - if (canonicalClaimPath && canonicalWritePath) { - restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath); - canonicalClaimPath = null; - } if (divergencePreserved) { throw err; } diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 0053a7da..a045c919 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -544,7 +544,18 @@ describe('SharedManager', () => { }); }); - it('preserves a canonical write that lands during no-replace publication', () => { + /** + * Replaces 'preserves a canonical write that lands during no-replace + * publication', which locked in the outcome of the CCS-4 incident. + * + * The intent it encoded - never clobber a writer that got to the canonical + * file first - is kept, but it is now enforced by the compare-and-swap + * guard instead of by an EEXIST from a no-replace link. The difference + * that matters: the canonical path is no longer emptied first, so only a + * genuinely concurrent write can land here, and it is preserved without + * costing the user the settings that were already there. + */ + it('refuses to publish when the canonical file changes before publication', () => { const manager = new SharedManager(); const instancePath = instanceDir('canonical-race'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -555,24 +566,132 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalLinkSync = fs.linkSync; - const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( - existingPath: fs.PathLike, - newPath: fs.PathLike + // Land the competing write while the publication temp file is being + // prepared, i.e. after the canonical bytes were read but before the + // compare-and-swap guard re-checks the inode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { writeJson(canonicalPath, { generation: 99 }); } - return originalLinkSync(existingPath, newPath); - }) as typeof fs.linkSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); - expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); - linkSpy.mockRestore(); + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Canonical file changed during adoption' + ); + openSpy.mockRestore(); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(divergedPath)).toEqual({ generation: 1 }); expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 }); + expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); + /** + * Model a foreign writer that shares ownership of the canonical + * settings.json: the instant the path is left without a file, it lands an + * empty-settings placeholder there. Claude Code does exactly this on + * startup, and a second concurrent `ccs` does the same through + * shared-dir-linker.ts:127. + * + * The writer reacts to the path becoming empty rather than to one specific + * call site, so it keeps modelling the race no matter which fs primitive + * the adopter uses to move the canonical file out of the way. + */ + function installForeignCanonicalWriter( + canonicalPath: string, + placeholder: string + ): { placeholderWrites: () => number; restore: () => void } { + let placeholderWrites = 0; + const claimEmptyCanonicalPath = (): void => { + if (fs.existsSync(canonicalPath)) return; + fs.writeFileSync(canonicalPath, placeholder, 'utf8'); + placeholderWrites++; + }; + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + claimEmptyCanonicalPath(); + }) as typeof fs.renameSync); + + const originalUnlinkSync = fs.unlinkSync; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + originalUnlinkSync(targetPath); + claimEmptyCanonicalPath(); + }) as typeof fs.unlinkSync); + + return { + placeholderWrites: () => placeholderWrites, + restore: () => { + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + }, + }; + } + + /** + * Reproduce the 2026-08-20 incident: adoption moves the canonical + * settings.json aside, a foreign writer fills the empty path, and the user + * ends up with neither the adopted nor the previous settings on the live + * path. + * + * The opposite outcome used to be pinned by 'preserves a canonical write + * that lands during no-replace publication'; that test was rewritten as + * 'refuses to publish when the canonical file changes before publication' + * once publication stopped emptying the canonical path. + */ + const foreignWriterCases: ReadonlyArray<{ writer: string; placeholder: string }> = [ + // Claude Code starts, finds no settings file and writes empty settings. + { writer: 'Claude Code', placeholder: '{}\n' }, + // A second concurrent `ccs` provisions the same placeholder without the + // trailing newline (shared-dir-linker.ts:127). + { writer: 'a concurrent ccs run', placeholder: JSON.stringify({}, null, 2) }, + ]; + + for (const { writer, placeholder } of foreignWriterCases) { + it(`keeps live settings when ${writer} fills the canonical path during adoption`, () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + const previousSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)'] }, + }; + const divergedSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)', 'Bash(git diff:*)'] }, + }; + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(canonicalPath, previousSettings); + writeJson(sharedSettingsPath, divergedSettings); + setMtime(sharedSettingsPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const foreignWriter = installForeignCanonicalWriter(canonicalPath, placeholder); + try { + manager.ensureSharedDirectories(); + } catch { + // Losing the race may abort reconciliation; the user's live settings + // must survive either way. + } finally { + foreignWriter.restore(); + } + + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.readFileSync(canonicalPath, 'utf8')).not.toBe(placeholder); + expect([divergedSettings, previousSettings]).toContainEqual(readJson(canonicalPath)); + }); + } + it('keeps adopted bytes recoverable when canonical changes after verification', () => { const manager = new SharedManager(); const instancePath = instanceDir('late-canonical-writer'); @@ -584,10 +703,12 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + // The diverged claim is dropped only after publication was verified, so + // a write injected there lands strictly after adoption completed. const originalUnlinkSync = fs.unlinkSync; let injected = false; const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + if (!injected && String(targetPath).includes('.ccs-adopt-claim-')) { injected = true; writeJson(canonicalPath, { generation: 99 }); } @@ -596,6 +717,7 @@ describe('SharedManager', () => { manager.linkSharedDirectories(instancePath); unlinkSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); @@ -611,20 +733,30 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalUnlinkSync = fs.unlinkSync; + // Recreate the managed source right after the canonical file was + // replaced, while the adopter is still cleaning up. + const originalRenameSync = fs.renameSync; let injected = false; - const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + if ( + !injected && + String(newPath) === canonicalPath && + String(oldPath).startsWith(`${canonicalPath}.ccs-write-`) + ) { injected = true; writeJson(divergedPath, { generation: 2 }); } - return originalUnlinkSync(targetPath); - }) as typeof fs.unlinkSync); + }) as typeof fs.renameSync); expect(() => manager.linkSharedDirectories(instancePath)).toThrow( 'Concurrent replacement detected' ); - unlinkSpy.mockRestore(); + renameSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(divergedPath)).toEqual({ generation: 2 }); }); @@ -694,7 +826,7 @@ describe('SharedManager', () => { } }); - it('uses the mode of the canonical inode actually claimed for publication', () => { + it('publishes with the mode the canonical inode carries at publication time', () => { const manager = new SharedManager(); const instancePath = instanceDir('concurrent-mode'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -706,23 +838,24 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalRenameSync = fs.renameSync; - const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( - oldPath: fs.PathLike, - newPath: fs.PathLike + // A chmod between reading the canonical file and publishing it leaves + // the content untouched, so publication proceeds with the newer mode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if ( - String(oldPath) === canonicalPath && - String(newPath).includes('.ccs-canonical-claim-') - ) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { fs.chmodSync(canonicalPath, 0o664); } - return originalRenameSync(oldPath, newPath); - }) as typeof fs.renameSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); manager.linkSharedDirectories(instancePath); - renameSpy.mockRestore(); + openSpy.mockRestore(); expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664); + expect(readJson(canonicalPath)).toEqual({ generation: 1 }); }); it('recovers an interrupted canonical claim before provisioning defaults', () => { From fa3fd8eb3a7c76781defe61771d9f8f3418f2a3e Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 23 Aug 2026 08:32:39 +0200 Subject: [PATCH 23/53] refactor(shared-manager): tighten canonical identity capture Build the canonical identity from the stat getCanonicalFile already takes, instead of a second lstat of the same inode. One syscall less, and mode, mtime and identity now describe the same moment rather than two adjacent ones. Assert in the adoption race tests that the foreign writer never fired. It writes only when the canonical path is observed empty, so a zero count states the invariant the fix establishes - the path is never left without a regular file - instead of only checking the final content. Built [OnSteroids](https://onsteroids.ai) --- .../shared-manager/diverged-file-adopter.ts | 16 ++++++---------- tests/unit/shared-manager.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 1ac67e28..41dbde84 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -148,9 +148,7 @@ interface CanonicalIdentity { size: number; } -function readCanonicalIdentity(writePath: string): CanonicalIdentity | null { - const stats = getLstatSync(writePath); - if (!stats?.isFile()) return null; +function canonicalIdentityOf(stats: fs.Stats): CanonicalIdentity { return { ino: stats.ino, mtimeMs: stats.mtimeMs, size: stats.size }; } @@ -246,9 +244,7 @@ function publishCanonicalContent( descriptor = null; const currentStats = getLstatSync(writePath); - const current = currentStats?.isFile() - ? { ino: currentStats.ino, mtimeMs: currentStats.mtimeMs, size: currentStats.size } - : null; + const current = currentStats?.isFile() ? canonicalIdentityOf(currentStats) : null; if (!canonicalIdentityMatches(expected, current)) { throw Object.assign(new TypeError(`Canonical file changed during adoption: ${writePath}`), { code: 'EEXIST', @@ -297,12 +293,12 @@ function getCanonicalFile(canonicalPath: string): { }); } - // Identity first: a write landing between the two reads leaves us holding - // newer bytes than the identity describes, and publication fails closed. - const identity = readCanonicalIdentity(writePath); + // The identity describes the inode as of the stat above, taken before the + // content read: a write landing in between leaves us holding newer bytes + // than the identity describes, and publication fails closed. return { content: fs.readFileSync(writePath), - identity, + identity: canonicalIdentityOf(canonicalStats), mode: canonicalStats.mode & 0o777, mtimeMs: canonicalStats.mtimeMs, writePath, diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index a045c919..7b1e70ea 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -686,6 +686,10 @@ describe('SharedManager', () => { foreignWriter.restore(); } + // The writer only fires when the canonical path is observed empty, so + // zero writes is the invariant itself: adoption never left the path + // without a regular file. + expect(foreignWriter.placeholderWrites()).toBe(0); expect(fs.existsSync(canonicalPath)).toBe(true); expect(fs.readFileSync(canonicalPath, 'utf8')).not.toBe(placeholder); expect([divergedSettings, previousSettings]).toContainEqual(readJson(canonicalPath)); From df52662a59517c495ea21056055c5d65ba1ba813 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 23 Aug 2026 08:32:53 +0200 Subject: [PATCH 24/53] refactor(shared-manager): extract the durable temp write createFileNoReplace and publishCanonicalContent carried the same open-wx / fchmod / write / fsync / close block and the same cleanup handler. Extract writeDurableTempFile and discardTempFile so each publisher is left with only what distinguishes it: a no-replace link, or the compare-and-swap guard and the rename. Also spell out in publishCanonicalContent that its guard is read-then-act rather than atomic. POSIX has no compare-and-swap rename, so the window is narrowed from the ~100 ms the old claim-and-republish path left open to two adjacent syscalls, not closed - and the pre-image sidecar is what keeps that last outcome recoverable. Worth stating so the guard is not mistaken for a strict guarantee later. Built [OnSteroids](https://onsteroids.ai) --- docs/reports/hardening-inventory.json | 80 +++++++++---------- docs/reports/hardening-inventory.md | 6 +- .../shared-manager/diverged-file-adopter.ts | 75 +++++++++-------- 3 files changed, 84 insertions(+), 77 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 1f1a0273..baf2e2d6 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,31 +1,11 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2516, + "totalOccurrences": 2509, "filesAffected": 263, - "hotpathOccurrences": 1190, + "hotpathOccurrences": 1183, "hotpathFilesAffected": 156, "topHotpathFiles": [ - { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 36, - "calls": [ - "chmodSync", - "closeSync", - "fsyncSync", - "linkSync", - "lstatSync", - "openSync", - "readdirSync", - "readFileSync", - "readlinkSync", - "renameSync", - "statSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/utils/browser/mcp-installer.ts", "count": 32, @@ -58,6 +38,26 @@ ], "markers": [] }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 29, + "calls": [ + "chmodSync", + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/utils/claude-symlink-manager.ts", "count": 27, @@ -259,26 +259,6 @@ ], "markers": [] }, - { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 36, - "calls": [ - "chmodSync", - "closeSync", - "fsyncSync", - "linkSync", - "lstatSync", - "openSync", - "readdirSync", - "readFileSync", - "readlinkSync", - "renameSync", - "statSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/cliproxy/executor/__tests__/composite-variant-service.test.ts", "count": 33, @@ -304,6 +284,22 @@ "writeFileSync" ], "markers": [] + }, + { + "file": "src/utils/browser/mcp-installer.ts", + "count": 32, + "calls": [ + "chmodSync", + "copyFileSync", + "existsSync", + "mkdirSync", + "readFileSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] } ] }, diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 43e4b23c..0753174f 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) | 2516 | +| Sync fs occurrences (all) | 2509 | | Sync fs files affected (all) | 263 | -| Sync fs occurrences (runtime hotpaths) | 1190 | +| Sync fs occurrences (runtime hotpaths) | 1183 | | Sync fs files affected (runtime hotpaths) | 156 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -17,9 +17,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Sync Calls | API Names | |---|---:|---| -| `src/management/shared-manager/diverged-file-adopter.ts` | 36 | chmodSync, closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/management/shared-manager/diverged-file-adopter.ts` | 29 | chmodSync, closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | | `src/cliproxy/config/env-builder.ts` | 25 | existsSync, mkdirSync, readFileSync, writeFileSync | | `src/management/shared-manager/migrations.ts` | 25 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync | diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 41dbde84..2e42f7e2 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -164,6 +164,36 @@ function canonicalIdentityMatches( ); } +function tempWritePath(targetPath: string): string { + return `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; +} + +/** + * Write content into a fresh temp file and make it durable, so whatever + * publishes it under its final name publishes complete bytes. + */ +function writeDurableTempFile(tempPath: string, content: Buffer, mode: number): void { + let descriptor: number | null = null; + try { + descriptor = fs.openSync(tempPath, 'wx', mode); + fs.fchmodSync(descriptor, mode); + fs.writeFileSync(descriptor, content); + fs.fsyncSync(descriptor); + } finally { + if (descriptor !== null) { + fs.closeSync(descriptor); + } + } +} + +function discardTempFile(tempPath: string): void { + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } +} + /** * Create a file only if the path is free, writing the content atomically. * @@ -171,26 +201,13 @@ function canonicalIdentityMatches( * cannot overwrite each other's sidecar artifacts. */ function createFileNoReplace(targetPath: string, content: Buffer, mode: number): void { - const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; - let descriptor: number | null = null; + const tempPath = tempWritePath(targetPath); try { - descriptor = fs.openSync(tempPath, 'wx', mode); - fs.fchmodSync(descriptor, mode); - fs.writeFileSync(descriptor, content); - fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - descriptor = null; + writeDurableTempFile(tempPath, content, mode); fs.linkSync(tempPath, targetPath); fs.unlinkSync(tempPath); } catch (err) { - if (descriptor !== null) { - fs.closeSync(descriptor); - } - try { - fs.unlinkSync(tempPath); - } catch { - // The temp file may not have been created or may already have been renamed. - } + discardTempFile(tempPath); throw err; } } @@ -226,6 +243,13 @@ function publishSidecarNoReplace(basePath: string, content: Buffer, mode: number * that got there first; the adopted bytes stay in the sidecars the caller * published. A pure chmod is not a content change, so the mode the inode * carries at publication time wins. + * + * The guard is read-then-act, not atomic: POSIX offers no compare-and-swap + * rename, so a writer landing between the check and the rename is still + * overwritten. That is a narrowing, not a guarantee - the window shrinks from + * the ~100 ms the old claim-and-republish path left open to two adjacent + * syscalls, and the pre-image sidecar the caller published keeps even that + * outcome recoverable. Do not build stricter guarantees on top of it. */ function publishCanonicalContent( writePath: string, @@ -233,15 +257,9 @@ function publishCanonicalContent( mode: number, expected: CanonicalIdentity | null ): void { - const tempPath = `${writePath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; - let descriptor: number | null = null; + const tempPath = tempWritePath(writePath); try { - descriptor = fs.openSync(tempPath, 'wx', mode); - fs.fchmodSync(descriptor, mode); - fs.writeFileSync(descriptor, content); - fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - descriptor = null; + writeDurableTempFile(tempPath, content, mode); const currentStats = getLstatSync(writePath); const current = currentStats?.isFile() ? canonicalIdentityOf(currentStats) : null; @@ -257,14 +275,7 @@ function publishCanonicalContent( } fs.renameSync(tempPath, writePath); } catch (err) { - if (descriptor !== null) { - fs.closeSync(descriptor); - } - try { - fs.unlinkSync(tempPath); - } catch { - // The temp file may not have been created or may already have been renamed. - } + discardTempFile(tempPath); throw err; } } From 4a17f034e24f1bcbed3067166e184e933cf17746 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 23 Aug 2026 08:33:06 +0200 Subject: [PATCH 25/53] docs(shared-manager): note why the CAS mismatch raises EEXIST The code is this file's marker for "a race was detected" - the same one the reappeared-path and concurrent-replacement guards raise - not a report that a no-replace link() or open('wx') hit an existing path. Say so at the throw, so debugging by err.code does not send anyone looking for a no-replace failure that never happened. Built [OnSteroids](https://onsteroids.ai) --- src/management/shared-manager/diverged-file-adopter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 2e42f7e2..fb075b5b 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -264,6 +264,9 @@ function publishCanonicalContent( const currentStats = getLstatSync(writePath); const current = currentStats?.isFile() ? canonicalIdentityOf(currentStats) : null; if (!canonicalIdentityMatches(expected, current)) { + // EEXIST is this file's marker for "a race was detected", the same code + // the reappeared-path and concurrent-replacement guards raise. It does + // not mean a no-replace link() or open('wx') hit an existing path. throw Object.assign(new TypeError(`Canonical file changed during adoption: ${writePath}`), { code: 'EEXIST', }); From 65dc9022996025eef5fcd3d7c1f42092a22fec40 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 18:54:51 +0000 Subject: [PATCH 26/53] chore(release): 8.9.0-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33101af6..5d43ddbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.4", + "version": "8.9.0-dev.5", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From eca266ad30851040f0605ae03ec6d5571032e90b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 25 Aug 2026 15:35:23 -0400 Subject: [PATCH 27/53] feat(cliproxy): clarify quota percentages are remaining in CLI output Closes #1714 --- .../cliproxy/quota-subcommand/handlers.ts | 2 +- .../quota-subcommand/sections/claude.ts | 2 +- .../quota-subcommand/sections/codex.ts | 2 +- .../quota-subcommand/sections/gemini-cli.ts | 2 +- .../cliproxy-quota-subcommand.test.ts | 103 ++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/commands/cliproxy/quota-subcommand/handlers.ts b/src/commands/cliproxy/quota-subcommand/handlers.ts index 84886720..a4e78574 100644 --- a/src/commands/cliproxy/quota-subcommand/handlers.ts +++ b/src/commands/cliproxy/quota-subcommand/handlers.ts @@ -138,7 +138,7 @@ export async function handleDoctor(verbose = false): Promise { for (const model of quota.models) { const bar = formatQuotaBar(model.percentage); - console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}% remaining`); } console.log(''); } diff --git a/src/commands/cliproxy/quota-subcommand/sections/claude.ts b/src/commands/cliproxy/quota-subcommand/sections/claude.ts index 88b19f8e..a2d50d68 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/claude.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/claude.ts @@ -93,7 +93,7 @@ export function displayClaudeQuotaSection( ? dim(' [warning]') : ''; console.log( - ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` + ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}% remaining${statusLabel}${resetLabel}` ); } console.log(''); diff --git a/src/commands/cliproxy/quota-subcommand/sections/codex.ts b/src/commands/cliproxy/quota-subcommand/sections/codex.ts index e2811734..fdc5d1ed 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/codex.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/codex.ts @@ -94,7 +94,7 @@ export function displayCodexQuotaSection( const resetValue = formatCodexWindowReset(window); const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; console.log( - ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}% remaining${resetLabel}` ); } console.log(''); diff --git a/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts index 287d34e4..f95cb8f3 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts @@ -67,7 +67,7 @@ export function displayGeminiCliQuotaSection( ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) : ''; console.log( - ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}` + ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}% remaining${tokenLabel}${amountLabel}${resetLabel}` ); } console.log(''); diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index dc1fa4b9..bf83c5f8 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { displayClaudeQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/claude'; +import { displayCodexQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/codex'; +import { displayGeminiCliQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/gemini-cli'; async function loadQuotaCommandTestExports() { const moduleId = Date.now() + Math.random(); @@ -167,3 +169,104 @@ describe('cliproxy quota subcommand Codex label formatting', () => { expect(label).not.toContain('\u0007'); }); }); + +function captureConsoleLog(fn: () => void): string { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + try { + fn(); + } finally { + console.log = originalLog; + } + return output.join('\n'); +} + +describe('cliproxy quota remaining percent labels', () => { + it('renders Claude remaining percent with a remaining suffix', () => { + const text = captureConsoleLog(() => { + displayClaudeQuotaSection([ + { + account: 'claude@example.com', + quota: { + success: true, + windows: [ + { + rateLimitType: 'five_hour', + label: '5h usage limit', + status: 'allowed', + utilization: 0.28, + usedPercent: 28, + remainingPercent: 72, + resetAt: null, + }, + ], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'claude@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + }); + + it('renders Codex remaining percent with a remaining suffix', () => { + const text = captureConsoleLog(() => { + displayCodexQuotaSection([ + { + account: 'codex@example.com', + quota: { + success: true, + windows: [ + { + label: 'Primary', + usedPercent: 28, + remainingPercent: 72, + resetAfterSeconds: null, + resetAt: null, + category: 'usage', + cadence: '5h', + }, + ], + planType: 'pro', + lastUpdated: 1, + accountId: 'codex@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + }); + + it('renders Gemini CLI remaining percent with a remaining suffix', () => { + const text = captureConsoleLog(() => { + displayGeminiCliQuotaSection([ + { + account: 'gemini@example.com', + quota: { + success: true, + buckets: [ + { + id: 'gemini-flash-series', + label: 'Gemini Flash Series', + tokenType: null, + remainingFraction: 0.72, + remainingPercent: 72, + resetTime: null, + modelIds: ['gemini-flash'], + }, + ], + projectId: null, + lastUpdated: 1, + accountId: 'gemini@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + }); +}); From f4911694e37aaa97dbdfefac0b0f712c4e81e435 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 25 Aug 2026 15:35:54 -0400 Subject: [PATCH 28/53] fix(websearch): filter disabled legacy CLI probes and skip unused version fetching Closes #1716 --- src/utils/websearch/agy.ts | 58 +++++++----- src/utils/websearch/gemini-cli.ts | 58 +++++++----- src/utils/websearch/grok-cli.ts | 58 +++++++----- src/utils/websearch/opencode-cli.ts | 58 +++++++----- src/utils/websearch/status.ts | 29 ++++-- tests/unit/utils/websearch/status.test.ts | 107 ++++++++++++++++++++++ 6 files changed, 267 insertions(+), 101 deletions(-) diff --git a/src/utils/websearch/agy.ts b/src/utils/websearch/agy.ts index f5528fdf..0a987728 100644 --- a/src/utils/websearch/agy.ts +++ b/src/utils/websearch/agy.ts @@ -22,35 +22,47 @@ let agyCliCache: AgyCliStatus | null = null; * * @returns Antigravity CLI status with path and version */ -export function getAgyCliStatus(): AgyCliStatus { - // Return cached result if available - if (agyCliCache) { +export function getAgyCliStatus(options?: { fetchVersion?: boolean }): AgyCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + agyCliCache && + (!fetchVersion || agyCliCache.version !== undefined || !agyCliCache.installed) + ) { + if (!fetchVersion) { + return { ...agyCliCache, version: undefined }; + } return agyCliCache; } - const result: AgyCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: AgyCliStatus = agyCliCache + ? { ...agyCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where agy' : 'which agy'; + if (!agyCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where agy' : 'which agy'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const agyPath = pathResult.trim().split('\n')[0]; // First result on Windows + const agyPath = pathResult.trim().split('\n')[0]; - if (agyPath) { - result.installed = true; - result.path = agyPath; + if (agyPath) { + result.installed = true; + result.path = agyPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('agy --version', { encoding: 'utf8', @@ -59,17 +71,17 @@ export function getAgyCliStatus(): AgyCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Antigravity CLI not installed } - // Cache result agyCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/gemini-cli.ts b/src/utils/websearch/gemini-cli.ts index 1069355e..6878b855 100644 --- a/src/utils/websearch/gemini-cli.ts +++ b/src/utils/websearch/gemini-cli.ts @@ -23,35 +23,47 @@ let geminiCliCache: GeminiCliStatus | null = null; * * @returns Gemini CLI status with path and version */ -export function getGeminiCliStatus(): GeminiCliStatus { - // Return cached result if available - if (geminiCliCache) { +export function getGeminiCliStatus(options?: { fetchVersion?: boolean }): GeminiCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + geminiCliCache && + (!fetchVersion || geminiCliCache.version !== undefined || !geminiCliCache.installed) + ) { + if (!fetchVersion) { + return { ...geminiCliCache, version: undefined }; + } return geminiCliCache; } - const result: GeminiCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: GeminiCliStatus = geminiCliCache + ? { ...geminiCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where gemini' : 'which gemini'; + if (!geminiCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where gemini' : 'which gemini'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows + const geminiPath = pathResult.trim().split('\n')[0]; - if (geminiPath) { - result.installed = true; - result.path = geminiPath; + if (geminiPath) { + result.installed = true; + result.path = geminiPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('gemini --version', { encoding: 'utf8', @@ -60,17 +72,17 @@ export function getGeminiCliStatus(): GeminiCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Gemini CLI not installed } - // Cache result geminiCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/grok-cli.ts b/src/utils/websearch/grok-cli.ts index 3115e6bb..d84a62e1 100644 --- a/src/utils/websearch/grok-cli.ts +++ b/src/utils/websearch/grok-cli.ts @@ -20,35 +20,47 @@ let grokCliCache: GrokCliStatus | null = null; * * @returns Grok CLI status with path and version */ -export function getGrokCliStatus(): GrokCliStatus { - // Return cached result if available - if (grokCliCache) { +export function getGrokCliStatus(options?: { fetchVersion?: boolean }): GrokCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + grokCliCache && + (!fetchVersion || grokCliCache.version !== undefined || !grokCliCache.installed) + ) { + if (!fetchVersion) { + return { ...grokCliCache, version: undefined }; + } return grokCliCache; } - const result: GrokCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: GrokCliStatus = grokCliCache + ? { ...grokCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where grok' : 'which grok'; + if (!grokCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where grok' : 'which grok'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows + const grokPath = pathResult.trim().split('\n')[0]; - if (grokPath) { - result.installed = true; - result.path = grokPath; + if (grokPath) { + result.installed = true; + result.path = grokPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('grok --version', { encoding: 'utf8', @@ -57,17 +69,17 @@ export function getGrokCliStatus(): GrokCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Grok CLI not installed } - // Cache result grokCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/opencode-cli.ts b/src/utils/websearch/opencode-cli.ts index 36a04cfc..4061d2a3 100644 --- a/src/utils/websearch/opencode-cli.ts +++ b/src/utils/websearch/opencode-cli.ts @@ -20,35 +20,47 @@ let opencodeCliCache: OpenCodeCliStatus | null = null; * * @returns OpenCode CLI status with path and version */ -export function getOpenCodeCliStatus(): OpenCodeCliStatus { - // Return cached result if available - if (opencodeCliCache) { +export function getOpenCodeCliStatus(options?: { fetchVersion?: boolean }): OpenCodeCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + opencodeCliCache && + (!fetchVersion || opencodeCliCache.version !== undefined || !opencodeCliCache.installed) + ) { + if (!fetchVersion) { + return { ...opencodeCliCache, version: undefined }; + } return opencodeCliCache; } - const result: OpenCodeCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: OpenCodeCliStatus = opencodeCliCache + ? { ...opencodeCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where opencode' : 'which opencode'; + if (!opencodeCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where opencode' : 'which opencode'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows + const opencodePath = pathResult.trim().split('\n')[0]; - if (opencodePath) { - result.installed = true; - result.path = opencodePath; + if (opencodePath) { + result.installed = true; + result.path = opencodePath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('opencode --version', { encoding: 'utf8', @@ -57,17 +69,17 @@ export function getOpenCodeCliStatus(): OpenCodeCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - OpenCode CLI not installed } - // Cache result opencodeCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 946fd28f..355c54ed 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -113,13 +113,23 @@ function applyCooldownStatus( }; } -function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearchCliInfo[] { - const agyStatus = getAgyCliStatus(); - const geminiStatus = getGeminiCliStatus(); - const grokStatus = getGrokCliStatus(); - const opencodeStatus = getOpenCodeCliStatus(); - const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); +function getLegacyProviderStatuses( + wsConfig: WebSearchConfigSnapshot, + options?: { includeVersions?: boolean } +): WebSearchCliInfo[] { + const fetchVersion = options?.includeVersions !== false; + const agyEnabled = wsConfig.providers?.agy?.enabled ?? false; + const geminiEnabled = wsConfig.providers?.gemini?.enabled ?? false; + const grokEnabled = wsConfig.providers?.grok?.enabled ?? false; + const opencodeEnabled = wsConfig.providers?.opencode?.enabled ?? false; + const agyStatus = agyEnabled ? getAgyCliStatus({ fetchVersion }) : { installed: false }; + const geminiStatus = geminiEnabled ? getGeminiCliStatus({ fetchVersion }) : { installed: false }; + const grokStatus = grokEnabled ? getGrokCliStatus({ fetchVersion }) : { installed: false }; + const opencodeStatus = opencodeEnabled + ? getOpenCodeCliStatus({ fetchVersion }) + : { installed: false }; + const geminiAuthed = geminiEnabled && geminiStatus.installed && isGeminiAuthenticated(); return [ { id: 'agy', @@ -198,7 +208,8 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch * Get all WebSearch providers with their current status. */ export function getWebSearchCliProviders( - wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig(), + options?: { includeVersions?: boolean } ): WebSearchCliInfo[] { const apiKeyStates = getWebSearchApiKeyStates(); const cooldowns = readProviderCooldowns(); @@ -284,7 +295,7 @@ export function getWebSearchCliProviders( }, ]; - return [...providers, ...getLegacyProviderStatuses(wsConfig)].map((provider) => + return [...providers, ...getLegacyProviderStatuses(wsConfig, options)].map((provider) => applyCooldownStatus(provider, cooldowns) ); } @@ -359,7 +370,7 @@ export function buildWebSearchReadiness( export function getWebSearchReadiness( wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() ): WebSearchStatus { - const providers = getWebSearchCliProviders(wsConfig); + const providers = getWebSearchCliProviders(wsConfig, { includeVersions: false }); return buildWebSearchReadiness(wsConfig.enabled, providers); } diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index f625c2a2..7a4116c6 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -301,6 +301,113 @@ describe('websearch readiness', () => { } }); + it('does not invoke legacy CLI status probes when those providers are disabled', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: true, max_results: 5 }, + agy: { enabled: false }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + } as any); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + } as any); + + try { + getWebSearchCliProviders(); + expect(agyStatusSpy).not.toHaveBeenCalled(); + expect(geminiStatusSpy).not.toHaveBeenCalled(); + expect(geminiAuthSpy).not.toHaveBeenCalled(); + expect(grokStatusSpy).not.toHaveBeenCalled(); + expect(opencodeStatusSpy).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('does not spawn --version when includeVersions is false', () => { + const wsConfig = { + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + agy: { enabled: true, model: 'gemini-2.5-flash', timeout: 90 }, + gemini: { enabled: true }, + grok: { enabled: true }, + opencode: { enabled: true }, + }, + }; + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: true, + version: undefined, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + + try { + getWebSearchCliProviders(wsConfig as Parameters[0], { + includeVersions: false, + }); + expect(agyStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(geminiStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(grokStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(opencodeStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + } finally { + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('treats cooled-down providers as temporarily unavailable in readiness status', () => { const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); From c0c36991d650cd4129ea1a910f0a3ad012373064 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 25 Aug 2026 15:56:12 -0400 Subject: [PATCH 29/53] fix(websearch): format status.ts --- src/utils/websearch/status.ts | 65 ++++++++++++++--------- tests/unit/utils/websearch/status.test.ts | 4 +- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 355c54ed..24839bf8 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -115,15 +115,17 @@ function applyCooldownStatus( function getLegacyProviderStatuses( wsConfig: WebSearchConfigSnapshot, - options?: { includeVersions?: boolean } + options?: { includeVersions?: boolean; probeDisabled?: boolean } ): WebSearchCliInfo[] { - const fetchVersion = options?.includeVersions !== false; - const agyEnabled = wsConfig.providers?.agy?.enabled ?? false; - const geminiEnabled = wsConfig.providers?.gemini?.enabled ?? false; - const grokEnabled = wsConfig.providers?.grok?.enabled ?? false; - const opencodeEnabled = wsConfig.providers?.opencode?.enabled ?? false; + const probeAll = options?.probeDisabled ?? true; + const fetchVersion = options?.includeVersions ?? true; + const agyEnabled = probeAll || (wsConfig.providers?.agy?.enabled ?? false); + const geminiEnabled = probeAll || (wsConfig.providers?.gemini?.enabled ?? false); + const grokEnabled = probeAll || (wsConfig.providers?.grok?.enabled ?? false); + const opencodeEnabled = probeAll || (wsConfig.providers?.opencode?.enabled ?? false); - const agyStatus = agyEnabled ? getAgyCliStatus({ fetchVersion }) : { installed: false }; + // agy detail renders its version on launch and in dashboard, so always fetch version when probed + const agyStatus = agyEnabled ? getAgyCliStatus({ fetchVersion: true }) : { installed: false }; const geminiStatus = geminiEnabled ? getGeminiCliStatus({ fetchVersion }) : { installed: false }; const grokStatus = grokEnabled ? getGrokCliStatus({ fetchVersion }) : { installed: false }; const opencodeStatus = opencodeEnabled @@ -143,11 +145,13 @@ function getLegacyProviderStatuses( docsUrl: 'https://antigravity.google/cli', requiresApiKey: false, description: 'Recommended LLM CLI fallback with Google web search (Gemini CLI successor).', - detail: agyStatus.installed - ? agyStatus.version - ? `Installed (${agyStatus.version})` - : 'Installed' - : 'Not installed', + detail: !agyEnabled + ? 'Disabled' + : agyStatus.installed + ? agyStatus.version + ? `Installed (${agyStatus.version})` + : 'Installed' + : 'Not installed', }, { id: 'gemini', @@ -162,11 +166,13 @@ function getLegacyProviderStatuses( requiresApiKey: false, description: 'Deprecated legacy fallback (Google retired the gemini CLI). Prefer Antigravity.', - detail: geminiStatus.installed - ? geminiAuthed - ? 'Authenticated' - : "Run 'gemini' to login" - : 'Not installed (retired - use Antigravity)', + detail: !geminiEnabled + ? 'Disabled' + : geminiStatus.installed + ? geminiAuthed + ? 'Authenticated' + : "Run 'gemini' to login" + : 'Not installed (retired - use Antigravity)', }, { id: 'opencode', @@ -180,7 +186,11 @@ function getLegacyProviderStatuses( docsUrl: 'https://github.com/sst/opencode', requiresApiKey: false, description: 'Optional legacy LLM fallback via OpenCode.', - detail: opencodeStatus.installed ? 'Installed' : 'Not installed', + detail: !opencodeEnabled + ? 'Disabled' + : opencodeStatus.installed + ? 'Installed' + : 'Not installed', }, { id: 'grok', @@ -195,11 +205,13 @@ function getLegacyProviderStatuses( requiresApiKey: true, apiKeyEnvVar: 'GROK_API_KEY', description: 'Optional legacy LLM fallback with xAI Grok.', - detail: grokStatus.installed - ? hasEnvValue('GROK_API_KEY') - ? 'Ready' - : 'Set GROK_API_KEY' - : 'Not installed', + detail: !grokEnabled + ? 'Disabled' + : grokStatus.installed + ? hasEnvValue('GROK_API_KEY') + ? 'Ready' + : 'Set GROK_API_KEY' + : 'Not installed', }, ]; } @@ -209,7 +221,7 @@ function getLegacyProviderStatuses( */ export function getWebSearchCliProviders( wsConfig: WebSearchConfigSnapshot = getWebSearchConfig(), - options?: { includeVersions?: boolean } + options?: { includeVersions?: boolean; probeDisabled?: boolean } ): WebSearchCliInfo[] { const apiKeyStates = getWebSearchApiKeyStates(); const cooldowns = readProviderCooldowns(); @@ -370,7 +382,10 @@ export function buildWebSearchReadiness( export function getWebSearchReadiness( wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() ): WebSearchStatus { - const providers = getWebSearchCliProviders(wsConfig, { includeVersions: false }); + const providers = getWebSearchCliProviders(wsConfig, { + includeVersions: false, + probeDisabled: false, + }); return buildWebSearchReadiness(wsConfig.enabled, providers); } diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 7a4116c6..2651ebec 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -336,7 +336,7 @@ describe('websearch readiness', () => { } as any); try { - getWebSearchCliProviders(); + getWebSearchCliProviders(undefined, { probeDisabled: false }); expect(agyStatusSpy).not.toHaveBeenCalled(); expect(geminiStatusSpy).not.toHaveBeenCalled(); expect(geminiAuthSpy).not.toHaveBeenCalled(); @@ -394,7 +394,7 @@ describe('websearch readiness', () => { getWebSearchCliProviders(wsConfig as Parameters[0], { includeVersions: false, }); - expect(agyStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(agyStatusSpy).toHaveBeenCalledWith({ fetchVersion: true }); expect(geminiStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); expect(grokStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); expect(opencodeStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); From 745cb743181362372fd5f89d20e67ea10cc4ed89 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 25 Aug 2026 16:04:33 -0400 Subject: [PATCH 30/53] fix(cliproxy): make formatQuotaBar pure ASCII and add doctor quota tests --- .../quota-subcommand/format-helpers.ts | 2 +- .../cliproxy-quota-subcommand.test.ts | 109 +++++++++++++++++- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/commands/cliproxy/quota-subcommand/format-helpers.ts b/src/commands/cliproxy/quota-subcommand/format-helpers.ts index a46116f5..c74fee6f 100644 --- a/src/commands/cliproxy/quota-subcommand/format-helpers.ts +++ b/src/commands/cliproxy/quota-subcommand/format-helpers.ts @@ -14,7 +14,7 @@ export function formatQuotaBar(percentage: number): string { const clampedPct = Math.max(0, Math.min(100, percentage)); const filled = Math.round((clampedPct / 100) * width); const empty = width - filled; - const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + const filledChar = clampedPct > 50 ? '#' : clampedPct > 10 ? '+' : '-'; return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; } diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index bf83c5f8..8b996c18 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { displayClaudeQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/claude'; import { displayCodexQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/codex'; import { displayGeminiCliQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/gemini-cli'; - +import { formatQuotaBar } from '../../../src/commands/cliproxy/quota-subcommand/format-helpers'; +import { handleDoctor } from '../../../src/commands/cliproxy/quota-subcommand/handlers'; +import type { AccountInfo } from '../../../src/cliproxy/accounts/types'; +import type { AllAccountsQuotaResult } from '../../../src/cliproxy/quota/quota-fetcher'; +import * as quotaFetcher from '../../../src/cliproxy/quota/quota-fetcher'; +import * as accountManager from '../../../src/cliproxy/accounts/account-manager'; async function loadQuotaCommandTestExports() { const moduleId = Date.now() + Math.random(); const mod = await import( @@ -182,8 +190,29 @@ function captureConsoleLog(fn: () => void): string { return output.join('\n'); } -describe('cliproxy quota remaining percent labels', () => { - it('renders Claude remaining percent with a remaining suffix', () => { +describe('cliproxy quota remaining percent labels and ASCII compliance', () => { + it('renders formatQuotaBar with pure ASCII characters', () => { + const bar100 = formatQuotaBar(100); + const bar50 = formatQuotaBar(50); + const bar25 = formatQuotaBar(25); + const bar5 = formatQuotaBar(5); + const bar0 = formatQuotaBar(0); + + expect(bar100).toBe(`[${'#'.repeat(20)}]`); + expect(bar50).toBe(`[${'+'.repeat(10)}${' '.repeat(10)}]`); + expect(bar25).toBe(`[${'+'.repeat(5)}${' '.repeat(15)}]`); + expect(bar5).toBe(`[${'-'.repeat(1)}${' '.repeat(19)}]`); + expect(bar0).toBe(`[${' '.repeat(20)}]`); + + // Strict ASCII verification + expect(bar100).toMatch(/^[\x00-\x7F]+$/); + expect(bar50).toMatch(/^[\x00-\x7F]+$/); + expect(bar25).toMatch(/^[\x00-\x7F]+$/); + expect(bar5).toMatch(/^[\x00-\x7F]+$/); + expect(bar0).toMatch(/^[\x00-\x7F]+$/); + }); + + it('renders Claude remaining percent with a remaining suffix and pure ASCII', () => { const text = captureConsoleLog(() => { displayClaudeQuotaSection([ { @@ -210,9 +239,10 @@ describe('cliproxy quota remaining percent labels', () => { }); expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); }); - it('renders Codex remaining percent with a remaining suffix', () => { + it('renders Codex remaining percent with a remaining suffix and pure ASCII', () => { const text = captureConsoleLog(() => { displayCodexQuotaSection([ { @@ -239,9 +269,10 @@ describe('cliproxy quota remaining percent labels', () => { }); expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); }); - it('renders Gemini CLI remaining percent with a remaining suffix', () => { + it('renders Gemini CLI remaining percent with a remaining suffix and pure ASCII', () => { const text = captureConsoleLog(() => { displayGeminiCliQuotaSection([ { @@ -268,5 +299,71 @@ describe('cliproxy quota remaining percent labels', () => { }); expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + }); + it('renders Doctor Antigravity quota with remaining suffix and pure ASCII', async () => { + const tempHome = mkdtempSync(join(tmpdir(), 'ccs-doctor-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + const mockAccount: AccountInfo = { + id: 'agy@example.com', + email: 'agy@example.com', + provider: 'agy', + isDefault: true, + tokenFile: 'agy-token.json', + createdAt: new Date().toISOString(), + }; + + const mockQuotaResult: AllAccountsQuotaResult = { + accounts: [ + { + account: mockAccount, + quota: { + success: true, + models: [ + { + name: 'gemini-2.5-flash', + percentage: 85, + resetTime: null, + }, + ], + projectId: 'proj-123', + lastUpdated: 1, + accountId: 'agy@example.com', + }, + }, + ], + projectGroups: {}, + }; + + const accountsSpy = spyOn(accountManager, 'getProviderAccounts').mockReturnValue([mockAccount]); + const fetchSpy = spyOn(quotaFetcher, 'fetchAllProviderQuotas').mockResolvedValue( + mockQuotaResult + ); + + try { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + try { + await handleDoctor(false); + } finally { + console.log = originalLog; + } + const text = output.join('\n'); + expect(text).toContain('gemini-2.5-flash'); + expect(text).toContain('85% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + } finally { + accountsSpy.mockRestore(); + fetchSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + rmSync(tempHome, { recursive: true, force: true }); + } }); }); From 51c603b0aa8f86899c543919af95d715eefdd488 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 20:07:25 +0000 Subject: [PATCH 31/53] chore(release): 8.9.0-dev.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d43ddbd..cf67dbd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.5", + "version": "8.9.0-dev.6", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From eddc404f2499b73acd96a8ec3b6d52c52916e05a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 25 Aug 2026 16:13:41 -0400 Subject: [PATCH 32/53] docs: refresh hardening inventory and add 75% quota test --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- tests/unit/commands/cliproxy-quota-subcommand.test.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index baf2e2d6..91e33acb 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -718,7 +718,7 @@ ] }, "largeFiles": { - "countOver400": 93, + "countOver400": 94, "countOver600": 42, "topOver400": [ { diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0753174f..506b59c8 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -62,7 +62,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | hotpath console.error/warn files | 81 | | 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 | 93 | +| files > 400 LOC | 94 | | files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 8b996c18..e8b31f93 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -193,12 +193,14 @@ function captureConsoleLog(fn: () => void): string { describe('cliproxy quota remaining percent labels and ASCII compliance', () => { it('renders formatQuotaBar with pure ASCII characters', () => { const bar100 = formatQuotaBar(100); + const bar75 = formatQuotaBar(75); const bar50 = formatQuotaBar(50); const bar25 = formatQuotaBar(25); const bar5 = formatQuotaBar(5); const bar0 = formatQuotaBar(0); expect(bar100).toBe(`[${'#'.repeat(20)}]`); + expect(bar75).toBe(`[${'#'.repeat(15)}${' '.repeat(5)}]`); expect(bar50).toBe(`[${'+'.repeat(10)}${' '.repeat(10)}]`); expect(bar25).toBe(`[${'+'.repeat(5)}${' '.repeat(15)}]`); expect(bar5).toBe(`[${'-'.repeat(1)}${' '.repeat(19)}]`); @@ -206,6 +208,7 @@ describe('cliproxy quota remaining percent labels and ASCII compliance', () => { // Strict ASCII verification expect(bar100).toMatch(/^[\x00-\x7F]+$/); + expect(bar75).toMatch(/^[\x00-\x7F]+$/); expect(bar50).toMatch(/^[\x00-\x7F]+$/); expect(bar25).toMatch(/^[\x00-\x7F]+$/); expect(bar5).toMatch(/^[\x00-\x7F]+$/); From d10b5653fff43aaf8e58475a3db9e35afbe24228 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 20:14:40 +0000 Subject: [PATCH 33/53] chore(release): 8.9.0-dev.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf67dbd1..66614a43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.6", + "version": "8.9.0-dev.7", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 768be0db8ae97f70b9afd9cdef0e55bdd666075b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 20:27:08 +0000 Subject: [PATCH 34/53] chore(release): 8.9.0-dev.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66614a43..b79b924e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.7", + "version": "8.9.0-dev.8", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 583d0cf8fca901885beeb10c5e9b379430e5a070 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 26 Aug 2026 23:01:07 -0400 Subject: [PATCH 35/53] fix(docs): update star history chart with encrypted sealed_token --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73c8e1f9..49a10a36 100644 --- a/README.md +++ b/README.md @@ -228,4 +228,4 @@ ccs ollama "summarize these logs" ## Star History -[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)type=Date[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)legend=top-left[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)sealed_token=ztbdUcU1tRfVPKPTvc-lMCRGPY6cLqpshj4rCGjkuQ41QKoJxwR_cNAbQ8itKG_JVP-kgju22G0vJpZiI3vcxfd10zaYUFq0_tyIVhlWzldVZYfCHa50qBwWzy4SenTIva_2pk49LYvDtaHNwNJxKt7wZ0Z5jhDQyxiDHc9BBsnCsjex8ub8Aea1BxU_)](https://star-history.com/#kaitranntt/ccs[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)Date) From 0598d2a7ba3fd2d8d0e09b825f9000d66c2e6f08 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 26 Aug 2026 23:01:17 -0400 Subject: [PATCH 36/53] fix(docs): format star history chart with valid sealed_token url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 49a10a36..b9a11196 100644 --- a/README.md +++ b/README.md @@ -228,4 +228,4 @@ ccs ollama "summarize these logs" ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)type=Date[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)legend=top-left[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)sealed_token=ztbdUcU1tRfVPKPTvc-lMCRGPY6cLqpshj4rCGjkuQ41QKoJxwR_cNAbQ8itKG_JVP-kgju22G0vJpZiI3vcxfd10zaYUFq0_tyIVhlWzldVZYfCHa50qBwWzy4SenTIva_2pk49LYvDtaHNwNJxKt7wZ0Z5jhDQyxiDHc9BBsnCsjex8ub8Aea1BxU_)](https://star-history.com/#kaitranntt/ccs[![Star History Chart](https://star-history.dera.page/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://star-history.dera.page/#kaitranntt/ccs&type=date&legend=top-left)Date) +[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs&type=Date&legend=top-left&sealed_token=ztbdUcU1tRfVPKPTvc-lMCRGPY6cLqpshj4rCGjkuQ41QKoJxwR_cNAbQ8itKG_JVP-kgju22G0vJpZiI3vcxfd10zaYUFq0_tyIVhlWzldVZYfCHa50qBwWzy4SenTIva_2pk49LYvDtaHNwNJxKt7wZ0Z5jhDQyxiDHc9BBsnCsjex8ub8Aea1BxU_)](https://star-history.com/#kaitranntt/ccs&Date) From 485c4369583631d7046ae5a6530713f3a24383da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Aug 2026 03:05:30 +0000 Subject: [PATCH 37/53] chore(release): 8.9.0-dev.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b79b924e..769194d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.8", + "version": "8.9.0-dev.9", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 3658c0113729514dc660af99ade379644c57186d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:09:34 -0400 Subject: [PATCH 38/53] feat(cliproxy): add meetsBackendMinimumVersion comparator helper --- .../__tests__/version-checker-suffix.test.ts | 42 ++++++++++++++++++- src/cliproxy/binary/version-checker.ts | 10 +++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts index 186942b0..c20c75ae 100644 --- a/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts +++ b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import { compareCliproxyVersions, isNewerVersion } from '../version-checker'; +import { + type CLIProxyBackendMinVersions, + compareCliproxyVersions, + isNewerVersion, + meetsBackendMinimumVersion, +} from '../version-checker'; describe('cliproxy version comparison', () => { it('treats missing fork release suffix as zero', () => { @@ -19,3 +24,38 @@ describe('cliproxy version comparison', () => { expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false); }); }); + +describe('meetsBackendMinimumVersion', () => { + const testMinimums: CLIProxyBackendMinVersions = { + original: '7.2.104', + plus: '7.2.105-0', + }; + + it('evaluates original backend boundary correctly', () => { + expect(meetsBackendMinimumVersion('7.2.103', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('7.2.104', 'original', testMinimums)).toBe(true); + expect(meetsBackendMinimumVersion('7.2.105', 'original', testMinimums)).toBe(true); + }); + + it('evaluates plus backend boundary with fork suffixes correctly', () => { + expect(meetsBackendMinimumVersion('7.2.104-9', 'plus', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('7.2.105-0', 'plus', testMinimums)).toBe(true); + expect(meetsBackendMinimumVersion('7.2.105-1', 'plus', testMinimums)).toBe(true); + }); + + it('treats missing fork suffix as equal to -0', () => { + const minWithSuffix: CLIProxyBackendMinVersions = { + original: '6.8.34-0', + plus: '6.8.34-0', + }; + expect(meetsBackendMinimumVersion('6.8.34', 'original', minWithSuffix)).toBe(true); + expect(meetsBackendMinimumVersion('6.8.34', 'plus', minWithSuffix)).toBe(true); + }); + + it('rejects empty and invalid version strings against real minimums', () => { + expect(meetsBackendMinimumVersion('', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('not-a-version', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('', 'plus', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('not-a-version', 'plus', testMinimums)).toBe(false); + }); +}); diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index f4e7c547..946dfc78 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -21,6 +21,8 @@ import { } from '../binary/platform-detector'; import type { CLIProxyBackend } from '../types'; +export type CLIProxyBackendMinVersions = Readonly>; + interface FetchLatestVersionDeps { fetchJsonFn?: typeof fetchJson; } @@ -78,6 +80,14 @@ export function isNewerVersion(latest: string, current: string): boolean { return compareCliproxyVersions(latest, current) > 0; } +export function meetsBackendMinimumVersion( + installedVersion: string, + backend: CLIProxyBackend, + minimumByBackend: CLIProxyBackendMinVersions +): boolean { + return compareCliproxyVersions(installedVersion, minimumByBackend[backend]) >= 0; +} + /** * Check if version is within the faulty range (v81-85) * @returns true if version has known critical bugs From 87f1d9b4e874f63a61d337357796db8e27a4f18f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:10:29 -0400 Subject: [PATCH 39/53] fix(cliproxy): correct ARM release asset architecture boundaries (#1725) --- src/cliproxy/binary/platform-detector.ts | 8 +- .../__tests__/backend-selection.test.js | 73 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index fdb78d83..9721afb0 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -84,7 +84,10 @@ const RELEASE_ARCH_MAP: Record = { }; const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0'; -const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1'; +const AARCH64_ASSET_MIN_VERSION: Readonly> = { + original: '6.9.48', + plus: '6.10.0-0', +}; export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined { const arch = ARCH_MAP[nodeArch]; @@ -122,9 +125,8 @@ function getReleaseArchForBackend( releaseArch: SupportedArch ): SupportedArch { if ( - backend === 'plus' && publicArch === 'arm64' && - !isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION) + !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend]) ) { return 'arm64'; } diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index e48d1091..54bec9a0 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -86,13 +86,84 @@ describe('Backend Selection', () => { ); }); }); - it('generates correct binary name for original backend', () => { const info = platformDetector.detectPlatform('6.6.51', 'original'); assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_')); assert(!info.binaryName.includes('CLIProxyAPIPlus')); }); + it('selects arm64 vs aarch64 release assets across original and plus boundaries', () => { + // Original pre-rename boundaries (<= 6.9.47) use arm64 + for (const version of [undefined, '6.9.45', '6.9.47']) { + const effectiveVersion = version || platformDetector.BACKEND_CONFIG.original.fallbackVersion; + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz` + ); + }); + + withMockedProcessPlatform('linux', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz` + ); + }); + + withMockedProcessPlatform('win32', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_windows_arm64.zip` + ); + }); + } + + // Original post-rename boundary (>= 6.9.48) uses aarch64 while public arch remains arm64 + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.9.48', 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz'); + assert.strictEqual( + platformDetector.getDownloadUrl('6.9.48', 'original'), + 'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.9.48/CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz' + ); + }); + + // Plus fallback / pre-rename (< 6.10.0-0) uses arm64 + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.9.45-0', 'plus'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.9.45-0_darwin_arm64.tar.gz'); + }); + + // Plus post-rename (>= 6.10.0-0) uses aarch64 + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.10.0-0', 'plus'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz'); + }); + + // x64 remains amd64 for both backends + withMockedProcessPlatform('linux', 'x64', () => { + assert.strictEqual( + platformDetector.detectPlatform('6.9.45', 'original').binaryName, + 'CLIProxyAPI_6.9.45_linux_amd64.tar.gz' + ); + assert.strictEqual( + platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName, + 'CLIProxyAPIPlus_6.9.45-0_linux_amd64.tar.gz' + ); + }); + }); it('uses static no-plugin assets when the integrated Linux image opts in', () => { withEnvironmentVariable('CCS_CLIPROXY_NO_PLUGIN_ASSET', '1', () => { withMockedProcessPlatform('linux', 'x64', () => { From b9601cb913dce355e2a5f3347500efe024e79974 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:11:47 -0400 Subject: [PATCH 40/53] fix(cliproxy): correct pool routing version capability map (#1726) --- .../__tests__/routing-strategy.test.ts | 91 ++++++++++++++++++- src/cliproxy/routing/routing-strategy.ts | 38 +++++--- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/routing/__tests__/routing-strategy.test.ts b/src/cliproxy/routing/__tests__/routing-strategy.test.ts index 3005620f..271496e0 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; - +import type { CLIProxyBackend } from '../../types'; describe('cliproxy routing strategy service', () => { let tempHome = ''; let scopedConfigDir = ''; @@ -15,6 +15,10 @@ describe('cliproxy routing strategy service', () => { protocol: 'http' as const, isRemote: false, }; + let binaryBackend: CLIProxyBackend = 'original'; + let binaryVersion = '7.2.127-7'; + let installedVersionQueriedBackend: CLIProxyBackend | undefined = undefined; + let loggedWarnings: Array<{ event: string; message: string; context?: Record }> = []; let responseFactory: (() => Promise) | null = null; beforeEach(async () => { @@ -28,6 +32,10 @@ describe('cliproxy routing strategy service', () => { }; responseFactory = null; originalCcsDir = process.env.CCS_DIR; + binaryBackend = 'original'; + binaryVersion = '7.2.127-7'; + installedVersionQueriedBackend = undefined; + loggedWarnings = []; originalCcsHome = process.env.CCS_HOME; process.env.CCS_DIR = scopedConfigDir; process.env.CCS_HOME = tempHome; @@ -74,6 +82,26 @@ describe('cliproxy routing strategy service', () => { }, })); + mock.module('../../binary-manager', () => ({ + getConfiguredBackend: () => binaryBackend, + getInstalledCliproxyVersion: (backend?: CLIProxyBackend) => { + installedVersionQueriedBackend = backend; + return binaryVersion; + }, + })); + + mock.module('../../../services/logging', () => ({ + createLogger: () => ({ + debug: () => {}, + info: () => {}, + warn: (event: string, message: string, context?: Record) => { + loggedWarnings.push({ event, message, context }); + }, + error: () => {}, + child: () => ({ warn: () => {} }), + }), + })); + return import(`../routing-strategy?test=${Date.now()}-${Math.random()}`); } @@ -365,4 +393,65 @@ describe('cliproxy routing strategy service', () => { expect(result.message).not.toContain('Pool routing is active'); }); }); + + describe('pool routing version compatibility (#1726)', () => { + it('defines backend-specific minimum versions for pool routing', async () => { + const mod = await loadRoutingModule(); + expect(mod.POOL_ROUTING_MIN_VERSION).toEqual({ + original: '6.8.34', + plus: '6.8.34-0', + }); + }); + + it('evaluates isPoolRoutingSupported correctly across original and plus thresholds', async () => { + const mod = await loadRoutingModule(); + expect(mod.isPoolRoutingSupported('original', '6.8.33')).toBe(false); + expect(mod.isPoolRoutingSupported('original', '6.8.34')).toBe(true); + expect(mod.isPoolRoutingSupported('original', '6.9.0')).toBe(true); + + expect(mod.isPoolRoutingSupported('plus', '6.8.33-9')).toBe(false); + expect(mod.isPoolRoutingSupported('plus', '6.8.34-0')).toBe(true); + expect(mod.isPoolRoutingSupported('plus', '7.2.127-7')).toBe(true); + + expect(mod.isPoolRoutingSupported('original', '')).toBe(false); + expect(mod.isPoolRoutingSupported('plus', 'not-a-version')).toBe(false); + }); + + it('warns with backend-specific metadata when plus binary is below minimum', async () => { + await withScopedConfig(async () => { + binaryBackend = 'plus'; + binaryVersion = '6.8.33-9'; + loggedWarnings = []; + + const mod = await loadRoutingModule(); + const result = mod.enablePoolRouting(8317); + + expect(result.changed).toBe(true); + expect(installedVersionQueriedBackend).toBe('plus'); + expect(loggedWarnings).toHaveLength(1); + expect(loggedWarnings[0].event).toBe('pool_routing.binary_below_minimum'); + expect(loggedWarnings[0].message).toContain('CLIProxy Plus'); + expect(loggedWarnings[0].context).toEqual({ + backend: 'plus', + installedVersion: '6.8.33-9', + minimumVersion: '6.8.34-0', + }); + }); + }); + + it('does not warn when plus binary meets the minimum version', async () => { + await withScopedConfig(async () => { + binaryBackend = 'plus'; + binaryVersion = '6.8.34-0'; + loggedWarnings = []; + + const mod = await loadRoutingModule(); + const result = mod.enablePoolRouting(8317); + + expect(result.changed).toBe(true); + expect(installedVersionQueriedBackend).toBe('plus'); + expect(loggedWarnings).toHaveLength(0); + }); + }); + }); }); diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index d025e858..3533cef0 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -7,10 +7,13 @@ import { getCliproxyRoutingTarget, getRoutingErrorMessage, } from './routing-strategy-http'; -import type { CliproxyRoutingStrategy } from '../types'; +import type { CLIProxyBackend, CliproxyRoutingStrategy } from '../types'; import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; -import { getInstalledCliproxyVersion } from '../binary-manager'; -import { compareVersions } from '../../utils/update-checker'; +import { getConfiguredBackend, getInstalledCliproxyVersion } from '../binary-manager'; +import { + type CLIProxyBackendMinVersions, + meetsBackendMinimumVersion, +} from '../binary/version-checker'; import { getConfigYamlPath } from '../../config/loader/io-locks'; import { createLogger } from '../../services/logging'; @@ -46,12 +49,18 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']); * max-retry-credentials and the cooling flip. * Older binaries silently ignore unknown keys — pool rails would appear active * but have no effect. Warn the user at enable time if below this version. - * - * NOTE: Update this constant when upstream first ships these keys. - * Current best estimate based on spec; adjust after spike Test D confirms. */ -export const POOL_ROUTING_MIN_VERSION = '6.9.45'; +export const POOL_ROUTING_MIN_VERSION: CLIProxyBackendMinVersions = { + original: '6.8.34', + plus: '6.8.34-0', +}; +export function isPoolRoutingSupported( + backend: CLIProxyBackend, + installedVersion: string +): boolean { + return meetsBackendMinimumVersion(installedVersion, backend, POOL_ROUTING_MIN_VERSION); +} /** * Pool-active override warning text. When pool routing is enabled the generator * forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so @@ -205,20 +214,21 @@ export function enablePoolRouting( const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity(); - // Spec step 3 / architecture: assert minimum CLIProxy version at enable time. - // Stale binaries silently ignore max-retry-credentials and the cooling flip, - // so pool rails would appear active but have no effect. Warn and proceed. try { - const installedVersion = getInstalledCliproxyVersion(); - if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) { + const backend = getConfiguredBackend(); + const installedVersion = getInstalledCliproxyVersion(backend); + if (!isPoolRoutingSupported(backend, installedVersion)) { + const minimumVersion = POOL_ROUTING_MIN_VERSION[backend]; + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; logger.warn( 'pool_routing.binary_below_minimum', - `CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` + + `${backendLabel} v${installedVersion} is older than the pool routing minimum (v${minimumVersion}). ` + `The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` + `Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`, { + backend, installedVersion, - minimumVersion: POOL_ROUTING_MIN_VERSION, + minimumVersion, } ); } From ed1badcfcb90f6e096ec06f28c27fecd270c0239 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:14:48 -0400 Subject: [PATCH 41/53] fix(cliproxy): fail-closed on unsupported drain-order priority selection (#1724) --- .../accounts/__tests__/drain-order.test.ts | 33 ++++ src/cliproxy/accounts/drain-order.ts | 23 ++- .../__tests__/order-subcommand.test.ts | 158 +++++++++++++++++- src/commands/cliproxy/order-subcommand.ts | 31 ++++ 4 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts index a8942d28..cc397a65 100644 --- a/src/cliproxy/accounts/__tests__/drain-order.test.ts +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -933,3 +933,36 @@ describe('fetchProxyAuthCooldowns', () => { }); }); }); + +describe('DRAIN_ORDER_MIN_VERSION and isDrainOrderSupported (#1724)', () => { + it('defines backend-specific minimum versions for drain-order priorities', async () => { + const { DRAIN_ORDER_MIN_VERSION } = await loadDrainOrder(); + expect(DRAIN_ORDER_MIN_VERSION).toEqual({ + original: '6.6.106', + plus: '6.6.107-0', + }); + }); + + it('evaluates original backend boundary correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '6.6.105')).toBe(false); + expect(isDrainOrderSupported('original', '6.6.106')).toBe(true); + expect(isDrainOrderSupported('original', '6.7.0')).toBe(true); + }); + + it('evaluates plus backend boundary with fork suffixes correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('plus', '6.6.105-9')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.106-0')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.107-0')).toBe(true); + expect(isDrainOrderSupported('plus', '7.2.127-7')).toBe(true); + }); + + it('rejects invalid and empty version strings', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '')).toBe(false); + expect(isDrainOrderSupported('original', 'invalid')).toBe(false); + expect(isDrainOrderSupported('plus', '')).toBe(false); + expect(isDrainOrderSupported('plus', 'invalid')).toBe(false); + }); +}); diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index 6f1e82ce..b8014818 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -21,11 +21,32 @@ import { } from '../proxy/proxy-target-resolver'; import { loadDrainOrderConfig } from './registry'; import type { AccountTier } from './types'; -import type { CLIProxyProvider } from '../types'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { + type CLIProxyBackendMinVersions, + meetsBackendMinimumVersion, +} from '../binary/version-checker'; /** Minimum valid priority value. Management layer treats 0 as delete. */ export const MIN_PRIORITY = 1; +/** + * Minimum CLIProxy version that supports drain-order priority selection. + * Original: v6.6.106 + * Plus: v6.6.107-0 + */ +export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = { + original: '6.6.106', + plus: '6.6.107-0', +}; + +export function isDrainOrderSupported( + backend: CLIProxyBackend, + installedVersion: string +): boolean { + return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION); +} + /** Management API path for patching auth file fields */ const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields'; diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index 8cc917d6..37fc0893 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -24,6 +24,7 @@ mock.module('../../../cliproxy/proxy/proxy-detector', () => ({ describe('handleOrderSubcommand', () => { let tempHome: string; let originalCcsHome: string | undefined; + let originalNoColor: string | undefined; let logSpy: ReturnType; let lines: string[]; @@ -35,7 +36,16 @@ describe('handleOrderSubcommand', () => { fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync( path.join(authDir(), fileName), - JSON.stringify({ type: 'claude', ...fields }, null, 2), + JSON.stringify({ type: 'claude', email: fileName, ...fields }, null, 2), + { mode: 0o600 } + ); + } + + function writeAgyAuthFile(fileName: string, fields: Record = {}): void { + fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir(), fileName), + JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2), { mode: 0o600 } ); } @@ -47,6 +57,24 @@ describe('handleOrderSubcommand', () => { >; } + async function loadRegistry() { + return import(`../../../cliproxy/accounts/registry?order-subcommand-reg=${Date.now()}`); + } + + async function configureBackend(backend: 'original' | 'plus', version: string): Promise { + const { mutateConfig, invalidateConfigCache } = await import( + '../../../config/config-loader-facade' + ); + mutateConfig((cfg) => { + if (!cfg.cliproxy) cfg.cliproxy = {}; + cfg.cliproxy.backend = backend; + }); + invalidateConfigCache(); + const verPath = path.join(tempHome, '.ccs', 'cliproxy', 'bin', backend, '.version'); + fs.mkdirSync(path.dirname(verPath), { recursive: true }); + fs.writeFileSync(verPath, version.trim() + '\n', 'utf-8'); + } + async function registerClaude(): Promise<{ registerAccount: (provider: string, tokenFile: string, email: string) => unknown; saveDrainOrderConfig: (provider: string, config: unknown) => boolean; @@ -64,7 +92,9 @@ describe('handleOrderSubcommand', () => { beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-')); originalCcsHome = process.env.CCS_HOME; + originalNoColor = process.env.NO_COLOR; process.env.CCS_HOME = tempHome; + process.env.NO_COLOR = '1'; process.exitCode = 0; lines = []; logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { @@ -80,6 +110,11 @@ describe('handleOrderSubcommand', () => { } else { delete process.env.CCS_HOME; } + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } fs.rmSync(tempHome, { recursive: true, force: true }); }); @@ -164,5 +199,126 @@ describe('handleOrderSubcommand', () => { expect(output).toContain('reset to file order'); expect(output).toContain('no priority set'); }); +}); + + describe('binary version capability gate (#1724)', () => { + const cases: Array<{ + backend: 'original' | 'plus'; + belowMinVersion: string; + atMinVersion: string; + backendLabel: string; + requiredMinVersion: string; + }> = [ + { + backend: 'original', + belowMinVersion: '6.6.105', + atMinVersion: '6.6.106', + backendLabel: 'CLIProxy', + requiredMinVersion: '6.6.106', + }, + { + backend: 'plus', + belowMinVersion: '6.6.105-0', + atMinVersion: '6.6.107-0', + backendLabel: 'CLIProxy Plus', + requiredMinVersion: '6.6.107-0', + }, + ]; + + for (const { backend, belowMinVersion, atMinVersion, backendLabel, requiredMinVersion } of cases) { + describe(`${backend} backend`, () => { + it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('claude-a.json')).toBe(false); + expect('priority' in readAuthFile('claude-b.json')).toBe(false); + expect(loadDrainOrderConfig('claude')).toBeUndefined(); + }); + + it(`refuses --by-tier below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(false); + expect('priority' in readAuthFile('antigravity-b.json')).toBe(false); + expect(loadDrainOrderConfig('agy')).toBeUndefined(); + }); + + it(`applies and persists --set at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('claude-a.json')).toBe(true); + expect(loadDrainOrderConfig('claude')?.mode).toBe('manual'); + }); + + it(`applies and persists --by-tier at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(true); + expect(loadDrainOrderConfig('agy')?.mode).toBe('tier'); + }); + }); + } }); }); diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts index b2b50061..29d3c6cf 100644 --- a/src/commands/cliproxy/order-subcommand.ts +++ b/src/commands/cliproxy/order-subcommand.ts @@ -12,6 +12,7 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../ import { extractOption, hasAnyFlag } from '../arg-extractor'; import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry'; import { getProviderAccounts } from '../../cliproxy/accounts/query'; +import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager'; import { computeManualDrainOrder, computeTierDrainOrder, @@ -19,6 +20,8 @@ import { resolveEffectiveDrainOrder, clearDrainOrderPriorities, tieBreakKey, + DRAIN_ORDER_MIN_VERSION, + isDrainOrderSupported, type DrainOrderEntry, type DrainOrderInput, } from '../../cliproxy/accounts/drain-order'; @@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s return tierDerived ? label : dim(tier); } +function ensureDrainOrderBinarySupport(): boolean { + const backend = getConfiguredBackend(); + const installedVersion = getInstalledCliproxyVersion(backend); + if (!isDrainOrderSupported(backend, installedVersion)) { + const minimumVersion = DRAIN_ORDER_MIN_VERSION[backend]; + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + console.log( + fail( + `${backendLabel} v${installedVersion} does not support drain-order priorities (requires v${minimumVersion} or newer).` + ) + ); + console.log( + info(`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`) + ); + console.log(''); + process.exitCode = 1; + return false; + } + return true; +} + function printOrderTable( entries: DrainOrderEntry[], showCurrentPriority: boolean, @@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise { const entries = computeTierDrainOrder(accounts); + if (!ensureDrainOrderBinarySupport()) { + return; + } console.log(subheader('Computed tier-based drain order:')); printOrderTable(entries, false); console.log(''); @@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi return; } + if (!ensureDrainOrderBinarySupport()) { + return; + } + console.log(subheader('Computed manual drain order:')); printOrderTable(entries, false); console.log(''); From 3ce7c01d95dbb52ba584a376201d3cad99c0b258 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:29:29 -0400 Subject: [PATCH 42/53] chore(cliproxy): refresh formatting, throw-error baseline, and hardening inventory --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- eslint-rules/throw-error-baseline.json | 24 +++++++++---------- src/cliproxy/accounts/drain-order.ts | 5 +--- src/cliproxy/binary/platform-detector.ts | 5 +--- .../__tests__/backend-selection.test.js | 8 +++++-- .../__tests__/routing-strategy.test.ts | 3 ++- .../__tests__/order-subcommand.test.ts | 12 ++++++++-- 8 files changed, 34 insertions(+), 27 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 91e33acb..c7a97bbc 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,7 +1,7 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2509, + "totalOccurrences": 2513, "filesAffected": 263, "hotpathOccurrences": 1183, "hotpathFilesAffected": 156, diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 506b59c8..ab01fc80 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,7 +6,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2509 | +| Sync fs occurrences (all) | 2513 | | Sync fs files affected (all) | 263 | | Sync fs occurrences (runtime hotpaths) | 1183 | | Sync fs files affected (runtime hotpaths) | 156 | diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index e1be244b..60d84fa8 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -8,12 +8,12 @@ "src/channels/official-channels-store.ts:291", "src/channels/official-channels-store.ts:296", "src/channels/official-channels-store.ts:299", - "src/cliproxy/accounts/drain-order.ts:131", - "src/cliproxy/accounts/drain-order.ts:139", - "src/cliproxy/accounts/drain-order.ts:246", - "src/cliproxy/accounts/drain-order.ts:253", - "src/cliproxy/accounts/drain-order.ts:279", - "src/cliproxy/accounts/drain-order.ts:342", + "src/cliproxy/accounts/drain-order.ts:149", + "src/cliproxy/accounts/drain-order.ts:157", + "src/cliproxy/accounts/drain-order.ts:264", + "src/cliproxy/accounts/drain-order.ts:271", + "src/cliproxy/accounts/drain-order.ts:297", + "src/cliproxy/accounts/drain-order.ts:360", "src/cliproxy/accounts/registry.ts:521", "src/cliproxy/accounts/registry.ts:536", "src/cliproxy/accounts/registry.ts:544", @@ -38,10 +38,10 @@ "src/cliproxy/binary/installer.ts:74", "src/cliproxy/binary/installer.ts:88", "src/cliproxy/binary/lifecycle.ts:137", - "src/cliproxy/binary/platform-detector.ts:153", - "src/cliproxy/binary/platform-detector.ts:160", + "src/cliproxy/binary/platform-detector.ts:152", + "src/cliproxy/binary/platform-detector.ts:159", "src/cliproxy/binary/verifier.ts:55", - "src/cliproxy/binary/version-checker.ts:108", + "src/cliproxy/binary/version-checker.ts:118", "src/cliproxy/config/base-config-loader.ts:54", "src/cliproxy/config/base-config-loader.ts:66", "src/cliproxy/config/base-config-loader.ts:81", @@ -59,9 +59,9 @@ "src/cliproxy/executor/proxy-resolver.ts:177", "src/cliproxy/executor/session-bridge.ts:147", "src/cliproxy/proxy/https-tunnel-proxy.ts:61", - "src/cliproxy/routing/routing-strategy.ts:480", - "src/cliproxy/routing/routing-strategy.ts:488", - "src/cliproxy/routing/routing-strategy.ts:708", + "src/cliproxy/routing/routing-strategy.ts:490", + "src/cliproxy/routing/routing-strategy.ts:498", + "src/cliproxy/routing/routing-strategy.ts:718", "src/cliproxy/services/remote-auth-fetcher.ts:139", "src/cliproxy/services/remote-auth-fetcher.ts:150", "src/cliproxy/services/remote-auth-fetcher.ts:152", diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index b8014818..ec2ee993 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -40,10 +40,7 @@ export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = { plus: '6.6.107-0', }; -export function isDrainOrderSupported( - backend: CLIProxyBackend, - installedVersion: string -): boolean { +export function isDrainOrderSupported(backend: CLIProxyBackend, installedVersion: string): boolean { return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION); } diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index 9721afb0..d9e5347b 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -124,10 +124,7 @@ function getReleaseArchForBackend( publicArch: SupportedArch, releaseArch: SupportedArch ): SupportedArch { - if ( - publicArch === 'arm64' && - !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend]) - ) { + if (publicArch === 'arm64' && !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend])) { return 'arm64'; } diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index 54bec9a0..40eb3de0 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -95,11 +95,15 @@ describe('Backend Selection', () => { it('selects arm64 vs aarch64 release assets across original and plus boundaries', () => { // Original pre-rename boundaries (<= 6.9.47) use arm64 for (const version of [undefined, '6.9.45', '6.9.47']) { - const effectiveVersion = version || platformDetector.BACKEND_CONFIG.original.fallbackVersion; + const effectiveVersion = + version || platformDetector.BACKEND_CONFIG.original.fallbackVersion; withMockedProcessPlatform('darwin', 'arm64', () => { const info = platformDetector.detectPlatform(version, 'original'); assert.strictEqual(info.arch, 'arm64'); - assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz`); + assert.strictEqual( + info.binaryName, + `CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz` + ); assert.strictEqual( platformDetector.getDownloadUrl(version, 'original'), `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz` diff --git a/src/cliproxy/routing/__tests__/routing-strategy.test.ts b/src/cliproxy/routing/__tests__/routing-strategy.test.ts index 271496e0..6a9364c1 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy.test.ts @@ -18,7 +18,8 @@ describe('cliproxy routing strategy service', () => { let binaryBackend: CLIProxyBackend = 'original'; let binaryVersion = '7.2.127-7'; let installedVersionQueriedBackend: CLIProxyBackend | undefined = undefined; - let loggedWarnings: Array<{ event: string; message: string; context?: Record }> = []; + let loggedWarnings: Array<{ event: string; message: string; context?: Record }> = + []; let responseFactory: (() => Promise) | null = null; beforeEach(async () => { diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index 37fc0893..90727b07 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -19,6 +19,8 @@ import * as path from 'path'; mock.module('../../../cliproxy/proxy/proxy-detector', () => ({ detectRunningProxy: async () => ({ running: false, verified: false }), + waitForProxyHealthy: async () => ({ running: false, verified: false }), + reclaimOrphanedProxy: () => {}, })); describe('handleOrderSubcommand', () => { @@ -199,7 +201,7 @@ describe('handleOrderSubcommand', () => { expect(output).toContain('reset to file order'); expect(output).toContain('no priority set'); }); -}); + }); describe('binary version capability gate (#1724)', () => { const cases: Array<{ @@ -225,7 +227,13 @@ describe('handleOrderSubcommand', () => { }, ]; - for (const { backend, belowMinVersion, atMinVersion, backendLabel, requiredMinVersion } of cases) { + for (const { + backend, + belowMinVersion, + atMinVersion, + backendLabel, + requiredMinVersion, + } of cases) { describe(`${backend} backend`, () => { it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => { await configureBackend(backend, belowMinVersion); From fcbac629d277c9fc65f81817ca70108fdc88c062 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 2 Sep 2026 11:35:00 -0400 Subject: [PATCH 43/53] test(cliproxy): strengthen fallback download url and ungated show/reset coverage --- .../__tests__/backend-selection.test.js | 26 ++++++++++++++----- .../__tests__/order-subcommand.test.ts | 16 +++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index 40eb3de0..116b3ddb 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -142,18 +142,32 @@ describe('Backend Selection', () => { ); }); - // Plus fallback / pre-rename (< 6.10.0-0) uses arm64 - withMockedProcessPlatform('darwin', 'arm64', () => { - const info = platformDetector.detectPlatform('6.9.45-0', 'plus'); - assert.strictEqual(info.arch, 'arm64'); - assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.9.45-0_darwin_arm64.tar.gz'); - }); + // Plus fallback (undefined) and explicit pre-rename (< 6.10.0-0) use arm64 + for (const version of [undefined, '6.9.45-0']) { + const effectiveVersion = version || platformDetector.BACKEND_CONFIG.plus.fallbackVersion; + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'plus'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual( + info.binaryName, + `CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz` + ); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'plus'), + `https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v${effectiveVersion}/CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz` + ); + }); + } // Plus post-rename (>= 6.10.0-0) uses aarch64 withMockedProcessPlatform('darwin', 'arm64', () => { const info = platformDetector.detectPlatform('6.10.0-0', 'plus'); assert.strictEqual(info.arch, 'arm64'); assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz'); + assert.strictEqual( + platformDetector.getDownloadUrl('6.10.0-0', 'plus'), + 'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v6.10.0-0/CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz' + ); }); // x64 remains amd64 for both backends diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index 90727b07..4ce07eb0 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -122,9 +122,8 @@ describe('handleOrderSubcommand', () => { describe('file-mode show with residual priorities', () => { it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => { - // Residual on-disk priorities, no stored config -> file mode + drift. - // claude-a sorts first alphabetically, but b has the higher priority, so - // the selector drains b first. The display must follow the selector. + // Explicit below-minimum version to prove show is ungated on old binaries. + await configureBackend('original', '6.6.105'); writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 }); writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 }); @@ -133,6 +132,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-b.json', 'b@x.com'); await runOrderSubcommand(['claude']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); // b@x.com (priority 5) must appear before a@x.com (priority 1). @@ -142,7 +142,6 @@ describe('handleOrderSubcommand', () => { expect(idxA).toBeGreaterThan(idxB); // Drift surfaced (same as the manual/tier branch), and the mode label no - // longer falsely claims "no priority set" under residual priorities. expect(output).toContain('Drift detected'); expect(output).toContain('residual priorities present'); expect(output).not.toContain('no priority set'); @@ -152,6 +151,8 @@ describe('handleOrderSubcommand', () => { }); it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => { + // Explicit below-minimum version on Plus to prove show is ungated on old binaries. + await configureBackend('plus', '6.6.105-0'); writeAuthFile('claude-a.json', { email: 'a@x.com' }); writeAuthFile('claude-b.json', { email: 'b@x.com' }); @@ -160,6 +161,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-b.json', 'b@x.com'); await runOrderSubcommand(['claude']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); expect(output).toContain('no priority set'); @@ -169,6 +171,8 @@ describe('handleOrderSubcommand', () => { describe('--reset clears residual priorities (proxy stopped -> direct write)', () => { it('removes the priority field from auth files and reports per-file results', async () => { + // Explicit below-minimum version to prove reset is ungated on old binaries. + await configureBackend('original', '6.6.105'); writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 }); writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear @@ -178,6 +182,7 @@ describe('handleOrderSubcommand', () => { saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); await runOrderSubcommand(['claude', '--reset']); + expect(process.exitCode).toBe(0); // Residual priority is actually gone from disk (not just config deleted). expect('priority' in readAuthFile('claude-a.json')).toBe(false); @@ -190,12 +195,15 @@ describe('handleOrderSubcommand', () => { }); it('reports already-clear files and still resets when no priorities exist', async () => { + // Explicit below-minimum version on Plus to prove reset is ungated on old binaries. + await configureBackend('plus', '6.6.105-0'); writeAuthFile('claude-a.json', { email: 'a@x.com' }); const { registerAccount } = await registerClaude(); registerAccount('claude', 'claude-a.json', 'a@x.com'); await runOrderSubcommand(['claude', '--reset']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); expect(output).toContain('reset to file order'); From 3583b544d01170d0352a49f3e4bfc8b5ecd2c953 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Thu, 3 Sep 2026 16:26:41 +0800 Subject: [PATCH 44/53] feat(models): add Claude Fable 5.1 and correct Sonnet 5 pricing Register `claude-fable-5-1` in the CLIProxy model catalog, the dashboard catalog, and the usage pricing registry. Pricing is taken from Anthropic's official pricing page: - Fable 5.1 base rates are $10/$50 per MTok with a $12.50 5m cache write, matching Fable 5. - Cache hits bill at 0.025x base input ($0.25/MTok) rather than the standard 0.1x multiplier. Anthropic applies that reduced rate only to Fable 5.1 and Mythos 5.1, so this entry cannot derive its cache rates from CACHE_READ_MULTIPLIER. This also corrects Claude Sonnet 5 from $3/$15 to $2/$10 (cache write $2.50, cache read $0.20). The launch introductory rate became the standard price and the increase scheduled for 2026-09-01 was cancelled, so the previous entry over-reported Sonnet 5 usage cost by 50%. Thinking on Fable 5.1 is always on and can only be steered through effort levels, so the catalog entry exposes the same `low`..`max` level surface as Fable 5 and Opus 5 instead of a manual token budget. The GitHub Copilot catalog is deliberately left unchanged, since Copilot availability for Fable 5.1 is not verified; surfacing it there would offer a model the backend may reject. Regenerates docs/reports/hardening-inventory.{json,md} so the ci-parity gate matches the source tree. --- docs/reports/hardening-inventory.json | 6 ++-- docs/reports/hardening-inventory.md | 4 +-- .../__tests__/thinking-validator.test.ts | 8 +++++ src/cliproxy/model-catalog.ts | 19 +++++++++++- src/web-server/model-pricing.ts | 31 +++++++++++++------ tests/unit/model-pricing.test.ts | 22 ++++++++++--- ui/src/lib/model-catalogs.ts | 14 ++++++++- ui/tests/unit/ui/lib/preset-utils.test.ts | 1 + 8 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 91e33acb..43bed65e 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -739,7 +739,7 @@ }, { "file": "src/web-server/model-pricing.ts", - "loc": 1127 + "loc": 1138 }, { "file": "src/cliproxy/config/generator.ts", @@ -778,8 +778,8 @@ "loc": 939 }, { - "file": "src/cliproxy/accounts/registry.ts", - "loc": 871 + "file": "src/cliproxy/model-catalog.ts", + "loc": 884 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 506b59c8..385cea08 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -93,7 +93,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | | `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `src/cursor/cursor-executor.ts` | 1234 | -| `src/web-server/model-pricing.ts` | 1127 | +| `src/web-server/model-pricing.ts` | 1138 | | `src/cliproxy/config/generator.ts` | 1109 | | `src/cliproxy/auth/oauth-process.ts` | 1048 | | `src/cliproxy/config/env-builder.ts` | 1045 | @@ -103,5 +103,5 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | -| `src/cliproxy/accounts/registry.ts` | 871 | +| `src/cliproxy/model-catalog.ts` | 884 | diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index b6a47489..41be1a1a 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -60,6 +60,14 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Claude Fable 5.1', () => { + // Fable 5.1 has always-on adaptive thinking steered only by effort level. + const result = validateThinking('claude', 'claude-fable-5-1', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should treat max as a distinct top tier on Claude Fable 5', () => { // Fable 5 shares Opus 4.8's adaptive thinking surface; max must remain // distinct from xhigh. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index ff2d632b..509639bc 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -531,10 +531,27 @@ export const MODEL_CATALOG: Partial> = }, extendedContext: true, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model (1M context, 128K output)', + contextWindow: 1000000, + nativeImageInput: true, + // Thinking is always on and cannot be disabled: Anthropic rejects both + // `thinking.type: "disabled"` and manual budget_tokens with 400. Depth is + // steered only through effort levels (default `high`). + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', contextWindow: 1000000, nativeImageInput: true, // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index b4eec3e5..d1cdfacf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, - // Claude Sonnet 5 ($3/$15) — latest Sonnet; shares the standard Sonnet rates. + // Claude Sonnet 5 ($2/$10) — the launch introductory rate is now the standard + // price; Anthropic cancelled the scheduled 2026-09-01 increase to $3/$15. // Registered explicitly so it is a known model rather than relying on the - // unknown-model fallback that coincidentally matches these rates. + // unknown-model fallback. 'claude-sonnet-5': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, 'claude-sonnet-5-thinking': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { @@ -360,6 +361,16 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 12.5, cacheReadPerMillion: 1.0, }, + // Claude Fable 5.1 ($10/$50) — same tier and base rates as Fable 5, but cache + // hits are billed at 0.025x base input ($0.25/MTok) instead of the usual 0.1x. + // Anthropic applies that reduced multiplier only to Fable 5.1 and Mythos 5.1, + // so this entry cannot use CACHE_READ_MULTIPLIER-derived rates. + 'claude-fable-5-1': { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, + cacheReadPerMillion: 0.25, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2df898fc..8202db38 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -277,12 +277,24 @@ describe('model-pricing', () => { expect(fable5.cacheReadPerMillion).toBe(1.0); }); + it('should return correct pricing for Claude Fable 5.1', () => { + // Same base rates as Fable 5, but cache hits bill at 0.025x base input + // ($0.25/MTok) rather than the standard 0.1x multiplier. + const fable51 = getModelPricing('claude-fable-5-1'); + expect(fable51.inputPerMillion).toBe(10.0); + expect(fable51.outputPerMillion).toBe(50.0); + expect(fable51.cacheCreationPerMillion).toBe(12.5); + expect(fable51.cacheReadPerMillion).toBe(0.25); + }); + it('should return correct pricing for Claude Sonnet 5', () => { + // The $2/$10 launch introductory rate became the standard price; the + // scheduled 2026-09-01 increase to $3/$15 was cancelled. const sonnet5 = getModelPricing('claude-sonnet-5'); - expect(sonnet5.inputPerMillion).toBe(3.0); - expect(sonnet5.outputPerMillion).toBe(15.0); - expect(sonnet5.cacheCreationPerMillion).toBe(3.75); - expect(sonnet5.cacheReadPerMillion).toBe(0.3); + expect(sonnet5.inputPerMillion).toBe(2.0); + expect(sonnet5.outputPerMillion).toBe(10.0); + expect(sonnet5.cacheCreationPerMillion).toBe(2.5); + expect(sonnet5.cacheReadPerMillion).toBe(0.2); }); it('should return Opus-tier pricing for Claude Opus 5', () => { @@ -430,7 +442,7 @@ describe('model-pricing', () => { cacheReadTokens: 1_000_000, }; const cost = calculateCost(usage, 'claude-sonnet-5'); - expect(cost).toBe(22.05); // 3 + 15 + 3.75 + 0.3 + expect(cost).toBe(14.7); // 2 + 10 + 2.5 + 0.2 }); it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 32c3d523..265e49e9 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -977,10 +977,22 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4-5-20251001', }, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model', + extendedContext: true, + presetMapping: { + default: 'claude-fable-5-1', + opus: 'claude-fable-5-1', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', extendedContext: true, presetMapping: { default: 'claude-fable-5', diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 06615ca0..d8ce4632 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -22,6 +22,7 @@ describe('claude preset utils', () => { expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5'); expect(ids).toContain('claude-sonnet-5'); + expect(ids).toContain('claude-fable-5-1'); expect(ids).toContain('claude-fable-5'); expect(ids).toContain('claude-opus-5'); expect(ids).toContain('claude-opus-4-8'); From 2b780ceae3bcc5a4cbaf809601e6d50fef1ae15b Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Thu, 3 Sep 2026 17:08:23 +0800 Subject: [PATCH 45/53] fix(extended-context): never write [1m] into the Fable tier key Claude Code resolves ANTHROPIC_DEFAULT_FABLE_MODEL through a resolver that strips the [1m] suffix before use, unlike the opus/sonnet resolvers which pass their env value through untouched. The stripped value is then treated as an env-supplied default and held to the standard 200k window instead of the model's native 1M, so writing [1m] into that key costs the long context window rather than granting it. Measured against a Claude Code 2.1.259 session (`/context` on a live proxy endpoint): ANTHROPIC_MODEL=claude-fable-5-1[1m] -> 1m window ANTHROPIC_MODEL=claude-fable-5-1 -> 200k window ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] -> 200k window The fix is key-scoped rather than model-scoped, since the suffix is what grants 1M on every other key. Adds envKeyAcceptsExtendedContextSuffix() as the single place recording which keys reject the suffix, enforces it as a floor inside applyExtendedContextPreferenceToAnthropicModels() so no caller can bypass it, and strips a previously saved suffix from that key on the next launch through the auto path. Also extends the preference to CLAUDE_CODE_SUBAGENT_MODEL. Subagent windows come from the same resolver as the main loop (the Explore inheritCap bounds the model tier, not the context window), so a bare subagent model id sits at 200k while the suffixed form gets 1M. The new key list is kept separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id normalization and profile validation. Claude-Session: https://claude.ai/code/session_01NPkafQjVf4pSwPBwBistGk --- .../config/extended-context-config.ts | 12 ++- src/shared/extended-context-utils.ts | 49 ++++++++++- .../cliproxy/extended-context-config.test.ts | 31 +++++++ .../shared/extended-context-utils.test.ts | 86 +++++++++++++++++++ .../provider-editor/use-provider-editor.ts | 6 +- ui/src/lib/extended-context-utils.ts | 4 + 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 tests/unit/cliproxy/extended-context-config.test.ts diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 98115888..443591d9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { - ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, + envKeyAcceptsExtendedContextSuffix, hasExtendedContextSuffix, isNativeGeminiModel, stripExtendedContextSuffix, @@ -95,13 +96,20 @@ export function applyExtendedContextConfig( // previously saved [1m] preference just because the model is Claude — only // strip when the model no longer supports extended context. Native Gemini // models still get auto-toggled based on catalog support. - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = envVars[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; } const modelId = stripModelConfigurationSuffixes(value); + // Keys whose resolver strips [1m] must never keep a saved suffix: the + // stripped value loses the model's native long context window. + if (!envKeyAcceptsExtendedContextSuffix(key)) { + envVars[key] = stripExtendedContextSuffix(value); + continue; + } + if (isNativeGeminiModel(modelId)) { envVars[key] = supportsExtendedContext(provider, modelId) ? applyExtendedContextSuffixShared(value) diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 57468cef..78419abd 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -14,8 +14,42 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; +/** + * Model env keys outside the Anthropic tier mappings that still carry a plain + * model id, so the extended-context preference has to cover them too. Kept + * separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id + * normalization and profile validation. + */ +export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = ['CLAUDE_CODE_SUBAGENT_MODEL'] as const; + +/** Every model env key the [1m] preference is applied to. */ +export const EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +] as const; + +export type ExtendedContextModelEnvKey = (typeof EXTENDED_CONTEXT_MODEL_ENV_KEYS)[number]; + const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); +const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set(EXTENDED_CONTEXT_MODEL_ENV_KEYS); + +/** + * Keys whose value Claude Code resolves through a resolver that removes [1m] + * before use. ANTHROPIC_DEFAULT_FABLE_MODEL is the only one today: its resolver + * strips the suffix (unlike the opus/sonnet resolvers, which pass the value + * through), and the stripped env-supplied default is then held to the standard + * 200k window instead of the model's native 1M. Writing [1m] here therefore + * costs the long context window rather than granting it, and Fable models are + * natively 1M, so the suffix is never needed on this key. + */ +const SUFFIX_STRIPPING_MODEL_ENV_KEYS = new Set(['ANTHROPIC_DEFAULT_FABLE_MODEL']); + +/** True when writing an explicit [1m] suffix into this env key is meaningful. */ +export function envKeyAcceptsExtendedContextSuffix(key: string): boolean { + return !SUFFIX_STRIPPING_MODEL_ENV_KEYS.has(key); +} + /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { return modelId.toLowerCase().startsWith('gemini-'); @@ -44,6 +78,11 @@ export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); } +/** True when key holds a model id the [1m] preference is applied to. */ +export function isExtendedContextModelEnvKey(key: string): key is ExtendedContextModelEnvKey { + return EXTENDED_CONTEXT_MODEL_ENV_KEY_SET.has(key); +} + /** Strip transient config suffixes so model IDs can be checked against catalogs. */ export function stripModelConfigurationSuffixes(modelId: string): string { return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); @@ -53,7 +92,7 @@ export function stripModelConfigurationSuffixes(modelId: string): string { export function hasAnthropicExtendedContextEnabled( env: Partial> ): boolean { - return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + return EXTENDED_CONTEXT_MODEL_ENV_KEYS.some((key) => { const value = env[key]; return typeof value === 'string' && hasExtendedContextSuffix(value); }); @@ -66,19 +105,21 @@ export function applyExtendedContextPreferenceToAnthropicModels< env: T, enabled: boolean, options: { - supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + supportsExtendedContext?: (modelId: string, key: ExtendedContextModelEnvKey) => boolean; } = {} ): T { const nextEnv: Record = { ...env }; - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = nextEnv[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; } const modelId = stripModelConfigurationSuffixes(value); - const supported = options.supportsExtendedContext?.(modelId, key) ?? true; + const supported = + envKeyAcceptsExtendedContextSuffix(key) && + (options.supportsExtendedContext?.(modelId, key) ?? true); nextEnv[key] = enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); } diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts new file mode 100644 index 00000000..29b7a575 --- /dev/null +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; + +import { applyExtendedContextConfig } from '../../../src/cliproxy/config/extended-context-config'; + +describe('applyExtendedContextConfig', () => { + it('strips a saved [1m] from the fable tier key in auto mode', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + }); + + it('keeps the fable tier key plain when extended context is forced on', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-fable-5-1[1m]'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-fable-5-1[1m]'); + }); +}); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index 3694f74c..bb319ce1 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'bun:test'; import { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, + applyExtendedContextPreferenceToAnthropicModels, + envKeyAcceptsExtendedContextSuffix, + hasAnthropicExtendedContextEnabled, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '../../../src/shared/extended-context-utils'; describe('Anthropic model env keys', () => { @@ -22,3 +27,84 @@ describe('Anthropic model env keys', () => { expect(isAnthropicModelEnvKey('ANTHROPIC_BASE_URL')).toBe(false); }); }); + +describe('extended-context model env keys', () => { + it('covers the Anthropic tiers plus the subagent model key', () => { + expect([...EXTENDED_CONTEXT_MODEL_ENV_KEYS]).toEqual([ + ...ANTHROPIC_MODEL_ENV_KEYS, + 'CLAUDE_CODE_SUBAGENT_MODEL', + ]); + expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); + }); + + it('marks the fable tier key as suffix-stripping', () => { + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(false); + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_MODEL')).toBe(true); + expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_OPUS_MODEL')).toBe(true); + expect(envKeyAcceptsExtendedContextSuffix('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + }); +}); + +describe('applyExtendedContextPreferenceToAnthropicModels', () => { + it('never writes [1m] into the fable tier key and strips a saved one', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }, + true + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-fable-5-1[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }); + }); + + it('strips every managed key when the preference is off', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }, + false + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }); + }); + + it('honors a caller compatibility predicate on top of the key guard', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + true, + { supportsExtendedContext: (modelId) => !modelId.startsWith('claude-haiku-') } + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('reads saved intent from the subagent key too', () => { + expect( + hasAnthropicExtendedContextEnabled({ CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]' }) + ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1' }) + ).toBe(false); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 35ac28b1..6bec47a4 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -12,7 +12,7 @@ import type { ProviderCatalog } from '../provider-model-selector'; import { applyExtendedContextPreferenceToAnthropicModels, hasAnthropicExtendedContextEnabled, - isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '@/lib/extended-context-utils'; import { supportsExtendedContext } from '@/lib/model-catalogs'; import { isValidProvider } from '@/lib/provider-config'; @@ -104,7 +104,7 @@ export function useProviderEditor( const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const envWithIntent = isAnthropicModelEnvKey(key) + const envWithIntent = isExtendedContextModelEnvKey(key) ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; delete envWithIntent['CCS_EXTENDED_CONTEXT']; @@ -132,7 +132,7 @@ export function useProviderEditor( const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const touchesAnthropicModel = Object.keys(updates).some(isExtendedContextModelEnvKey); const envWithIntent = touchesAnthropicModel ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 2ce76d69..aa7fde05 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -4,10 +4,14 @@ export { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, + envKeyAcceptsExtendedContextSuffix, isNativeGeminiModel, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix, From 02c45e088a841fe9a8d6192743f18a8f99cd2724 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Fri, 4 Sep 2026 02:48:15 +0800 Subject: [PATCH 46/53] fix(extended-context): suffix the Fable tier and default it on 1M launches The previous commit assumed Claude Code's fable resolver strips [1m] and therefore never wrote the suffix into ANTHROPIC_DEFAULT_FABLE_MODEL. That premise only holds when ANTHROPIC_BASE_URL is unset or points at api.anthropic.com. Behind a proxy (CLIProxy, headroom, ...) Claude Code 2.1.259 does the opposite: - the fable alias resolver passes the env value through untouched, so a saved `claude-fable-5-1[1m]` reaches the model picker as-is; - the context-window resolver grants 1M unconditionally when the model id carries [1m], and otherwise trusts a natively-1M model only when the base URL is first-party. Bare `claude-fable-5-1` via 127.0.0.1 is clamped to 200k even though CLIProxy's /v1/models advertises max_input_tokens 1M. So the suffix is the only thing that turns the long window on for CCS, and stripping it from the Fable key is what kept `ccs claude --model fable` at 200k. A headroom settings profile with the suffix saved on that key showed the 1M window in the same Claude Code build, which is how the inversion was caught. Changes: - Drop the suffix-stripping key guard; ANTHROPIC_DEFAULT_FABLE_MODEL now receives and keeps [1m] exactly like the opus/sonnet tiers. - On a long-context launch (explicit --1m, or a saved [1m] on any Anthropic tier key) fill a missing Fable tier with the catalog Fable model plus [1m]. The model-neutral claude profile pins no Fable model, so `--model fable` used to fall back to Claude Code's bare default and lose the window even when every other tier asked for 1M. An explicit mapping always wins, --no-1m never fills, and providers without a catalog Fable model are left alone. - Add getDefaultFableTierModel() to the model catalog for that default. Verified with a stub Claude binary that dumps its environment: ccs claude --model fable -> ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] ccs claude --1m -> ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] ccs claude --no-1m --model fable -> no Fable tier written, ANTHROPIC_MODEL stripped --- .../config/extended-context-config.ts | 51 ++++++++-- src/cliproxy/model-catalog.ts | 11 +++ src/shared/extended-context-utils.ts | 26 ++--- .../cliproxy/extended-context-config.test.ts | 94 ++++++++++++++++++- .../shared/extended-context-utils.test.ts | 23 ++--- ui/src/lib/extended-context-utils.ts | 1 - 6 files changed, 162 insertions(+), 44 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 443591d9..98fb26c9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -10,13 +10,13 @@ */ import type { CLIProxyProvider } from '../types'; -import { supportsExtendedContext } from '../model-catalog'; +import { getDefaultFableTierModel, supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { + ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, - envKeyAcceptsExtendedContextSuffix, hasExtendedContextSuffix, isNativeGeminiModel, stripExtendedContextSuffix, @@ -64,6 +64,39 @@ export function shouldApplyExtendedContext( return false; } +/** + * Whether a saved Anthropic tier mapping still carries a [1m] suffix after the + * per-key pass, i.e. the launch is a long-context launch. + */ +function hasSavedLongContextTier(envVars: NodeJS.ProcessEnv): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = envVars[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** + * Give a long-context launch a fable tier when the profile does not map one. + * + * Claude Code falls back to its own bare Fable id for `--model fable` / the + * fable tier, and behind a proxy base URL a bare natively-1M id is clamped to + * the standard 200k window. Only the [1m] suffix lifts that clamp, so a + * model-neutral claude profile that asked for 1M elsewhere would otherwise get + * a 200k Fable. An explicit mapping (dashboard "fable tier" field or settings + * file) always wins; providers without a catalog Fable model are untouched. + */ +function fillFableTierForLongContext(envVars: NodeJS.ProcessEnv, provider: CLIProxyProvider): void { + const existing = envVars.ANTHROPIC_DEFAULT_FABLE_MODEL; + if (typeof existing === 'string' && existing.trim().length > 0) { + return; + } + const fableModel = getDefaultFableTierModel(provider); + if (!fableModel || !supportsExtendedContext(provider, fableModel)) { + return; + } + envVars.ANTHROPIC_DEFAULT_FABLE_MODEL = applyExtendedContextSuffixShared(fableModel); +} + /** * Apply extended context configuration to env vars. * Modifies ANTHROPIC_MODEL and tier models with [1m] suffix. @@ -89,6 +122,9 @@ export function applyExtendedContextConfig( ), }) ); + if (extendedContextOverride) { + fillFableTierForLongContext(envVars, provider); + } return; } @@ -103,13 +139,6 @@ export function applyExtendedContextConfig( } const modelId = stripModelConfigurationSuffixes(value); - // Keys whose resolver strips [1m] must never keep a saved suffix: the - // stripped value loses the model's native long context window. - if (!envKeyAcceptsExtendedContextSuffix(key)) { - envVars[key] = stripExtendedContextSuffix(value); - continue; - } - if (isNativeGeminiModel(modelId)) { envVars[key] = supportsExtendedContext(provider, modelId) ? applyExtendedContextSuffixShared(value) @@ -121,4 +150,8 @@ export function applyExtendedContextConfig( envVars[key] = stripExtendedContextSuffix(value); } } + + if (hasSavedLongContextTier(envVars)) { + fillFableTierForLongContext(envVars, provider); + } } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 509639bc..0b6cb2f8 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -866,6 +866,17 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str return model?.extendedContext === true; } +/** + * Catalog model that backs the Claude Code `fable` tier for a provider. + * The catalog lists the newest Fable first, so the first match is the default. + * Returns undefined for providers that serve no Fable model. + */ +export function getDefaultFableTierModel(provider: CLIProxyProvider): string | undefined { + const catalog = MODEL_CATALOG[provider]; + if (!catalog) return undefined; + return catalog.models.find((model) => model.id.toLowerCase().startsWith('claude-fable-'))?.id; +} + /** * Check if a model can read image inputs natively. */ diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 78419abd..8c717720 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -34,21 +34,15 @@ const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set(EXTENDED_CONTEXT_MODEL_ENV_KEYS); -/** - * Keys whose value Claude Code resolves through a resolver that removes [1m] - * before use. ANTHROPIC_DEFAULT_FABLE_MODEL is the only one today: its resolver - * strips the suffix (unlike the opus/sonnet resolvers, which pass the value - * through), and the stripped env-supplied default is then held to the standard - * 200k window instead of the model's native 1M. Writing [1m] here therefore - * costs the long context window rather than granting it, and Fable models are - * natively 1M, so the suffix is never needed on this key. +/* + * Why every key, including ANTHROPIC_DEFAULT_FABLE_MODEL, carries the suffix: + * Claude Code only grants a natively-1M model (Fable, Opus 5) its full window + * without the suffix when ANTHROPIC_BASE_URL is unset or points at + * api.anthropic.com. Behind any proxy (CLIProxy, headroom, ...) a bare id is + * clamped to 200k even when the backend advertises 1M, and the fable alias + * resolver passes the env value through untouched in that case. The [1m] + * suffix is therefore the only thing that turns the long window on for CCS. */ -const SUFFIX_STRIPPING_MODEL_ENV_KEYS = new Set(['ANTHROPIC_DEFAULT_FABLE_MODEL']); - -/** True when writing an explicit [1m] suffix into this env key is meaningful. */ -export function envKeyAcceptsExtendedContextSuffix(key: string): boolean { - return !SUFFIX_STRIPPING_MODEL_ENV_KEYS.has(key); -} /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { @@ -117,9 +111,7 @@ export function applyExtendedContextPreferenceToAnthropicModels< } const modelId = stripModelConfigurationSuffixes(value); - const supported = - envKeyAcceptsExtendedContextSuffix(key) && - (options.supportsExtendedContext?.(modelId, key) ?? true); + const supported = options.supportsExtendedContext?.(modelId, key) ?? true; nextEnv[key] = enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); } diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 29b7a575..b8422b9f 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'bun:test'; import { applyExtendedContextConfig } from '../../../src/cliproxy/config/extended-context-config'; describe('applyExtendedContextConfig', () => { - it('strips a saved [1m] from the fable tier key in auto mode', () => { + it('keeps a saved [1m] on the fable tier key in auto mode', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-opus-5[1m]', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', @@ -11,11 +11,11 @@ describe('applyExtendedContextConfig', () => { applyExtendedContextConfig(env, 'claude'); - expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); }); - it('keeps the fable tier key plain when extended context is forced on', () => { + it('suffixes the fable tier key like every other tier when forced on', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-fable-5-1', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', @@ -24,8 +24,94 @@ describe('applyExtendedContextConfig', () => { applyExtendedContextConfig(env, 'claude', true); - expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.ANTHROPIC_MODEL).toBe('claude-fable-5-1[1m]'); expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-fable-5-1[1m]'); }); + + it('strips the fable tier key with the others when forced off', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + }); + + describe('fable tier default on the claude provider', () => { + it('fills a missing fable tier with the catalog Fable model plus [1m] when a saved tier carries [1m]', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier when --1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier on a model-neutral claude launch when --1m is passed', () => { + const env: NodeJS.ProcessEnv = {}; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + expect(Object.keys(env)).toEqual(['ANTHROPIC_DEFAULT_FABLE_MODEL']); + }); + + it('leaves the fable tier alone when no long-context intent exists', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('leaves the fable tier alone when --no-1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('never overrides an explicit fable tier mapping', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5[1m]'); + }); + + it('does not invent a fable tier for providers without Fable models', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-5.4[1m]', + }; + + applyExtendedContextConfig(env, 'codex'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index bb319ce1..a65af8b1 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -4,7 +4,6 @@ import { ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, - envKeyAcceptsExtendedContextSuffix, hasAnthropicExtendedContextEnabled, isAnthropicModelEnvKey, isExtendedContextModelEnvKey, @@ -37,22 +36,17 @@ describe('extended-context model env keys', () => { expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); }); - - it('marks the fable tier key as suffix-stripping', () => { - expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(false); - expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_MODEL')).toBe(true); - expect(envKeyAcceptsExtendedContextSuffix('ANTHROPIC_DEFAULT_OPUS_MODEL')).toBe(true); - expect(envKeyAcceptsExtendedContextSuffix('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); - }); }); describe('applyExtendedContextPreferenceToAnthropicModels', () => { - it('never writes [1m] into the fable tier key and strips a saved one', () => { + it('writes [1m] into every managed key, the fable tier included', () => { + // Behind a proxy base URL Claude Code clamps a bare Fable id to 200k, so the + // fable tier needs the suffix exactly like the opus/sonnet tiers do. const env = applyExtendedContextPreferenceToAnthropicModels( { ANTHROPIC_MODEL: 'claude-fable-5-1', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5', - ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', }, true @@ -61,7 +55,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { expect(env).toEqual({ ANTHROPIC_MODEL: 'claude-fable-5-1[1m]', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5[1m]', - ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', }); }); @@ -83,7 +77,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { }); }); - it('honors a caller compatibility predicate on top of the key guard', () => { + it('honors a caller compatibility predicate', () => { const env = applyExtendedContextPreferenceToAnthropicModels( { ANTHROPIC_MODEL: 'claude-opus-5', @@ -99,10 +93,13 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { }); }); - it('reads saved intent from the subagent key too', () => { + it('reads saved intent from any managed key', () => { expect( hasAnthropicExtendedContextEnabled({ CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]' }) ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]' }) + ).toBe(true); expect( hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1' }) ).toBe(false); diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index aa7fde05..1dbb35b8 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -8,7 +8,6 @@ export { EXTENDED_CONTEXT_SUFFIX, EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, - envKeyAcceptsExtendedContextSuffix, isNativeGeminiModel, isAnthropicModelEnvKey, isExtendedContextModelEnvKey, From 7ae7a5eba6c75871f6822567079d841d9cd73789 Mon Sep 17 00:00:00 2001 From: Kenneth Wong Date: Fri, 4 Sep 2026 08:34:33 +0800 Subject: [PATCH 47/53] fix(extended-context): manage ANTHROPIC_DEFAULT_MODEL and keep the overlay in sync Claude Code reads ANTHROPIC_DEFAULT_MODEL as its lowest-priority startup model (after --model, ANTHROPIC_MODEL and the settings `model` field). It is a plain model id, so behind a proxy a bare value is clamped to 200k exactly like the tier keys. Add it to EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS so --1m/--no-1m and the saved preference toggle it with the rest. The `--settings` launch overlay only carried the routing keys plus the five Anthropic tier keys. Claude Code applies the settings `env` block on top of the process environment, so a bare CLAUDE_CODE_SUBAGENT_MODEL or ANTHROPIC_DEFAULT_MODEL persisted on disk silently undid the resolved --1m result (and a saved [1m] undid --no-1m). Overlay the extended-context extra keys too. Verified with a stub Claude binary reading the overlay file: ccs claude --no-1m -> ANTHROPIC_DEFAULT_MODEL=claude-opus-5, CLAUDE_CODE_SUBAGENT_MODEL=claude-sonnet-5 ccs claude --1m -> both keys suffixed with [1m] --- .../__tests__/launch-settings.test.ts | 24 +++++++++++++++++++ src/cliproxy/executor/launch-settings.ts | 11 +++++++-- src/shared/extended-context-utils.ts | 9 ++++++- .../shared/extended-context-utils.test.ts | 16 ++++++++++++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index 6080ab27..0b313b84 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -57,6 +57,30 @@ describe('buildLaunchSettingsOverlay', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); + it('overlays the extended-context extra model keys so a saved bare value cannot clobber --1m/--no-1m', () => { + writePersisted({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5[1m]', + }, + }); + + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + const env = settings.env as Record; + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_MODEL).toBe('claude-opus-5'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-sonnet-5'); + }); + it('preserves non-env settings (permissions, hooks, etc.)', () => { writePersisted({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, diff --git a/src/cliproxy/executor/launch-settings.ts b/src/cliproxy/executor/launch-settings.ts index 5e33bf7e..aadbd96e 100644 --- a/src/cliproxy/executor/launch-settings.ts +++ b/src/cliproxy/executor/launch-settings.ts @@ -26,6 +26,7 @@ import * as os from 'os'; import * as path from 'path'; import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor'; +import { EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; // SIBLING HELPER: src/utils/openai-compat-launch-settings.ts solves the same // "persisted --settings env clobbers runtime routing env" problem by STRIPPING @@ -38,9 +39,15 @@ import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../util * Environment keys that control provider routing/model selection and are read * by Claude from the settings `env` block. These must reflect the resolved * proxy-chain environment, not the persisted on-disk values. Reuses the - * canonical routing/model key lists from shell-executor. + * canonical routing/model key lists from shell-executor, plus the extra model + * keys the [1m] preference manages (subagent + startup default): a saved bare + * value there would otherwise clobber the resolved --1m/--no-1m result. */ -const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS]; +const ROUTING_ENV_KEYS = [ + ...ANTHROPIC_ROUTING_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 8c717720..ebed49c9 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -19,8 +19,15 @@ export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; * model id, so the extended-context preference has to cover them too. Kept * separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id * normalization and profile validation. + * + * ANTHROPIC_DEFAULT_MODEL is Claude Code's lowest-priority startup model + * (after --model, ANTHROPIC_MODEL and the settings `model` field), so it needs + * the same [1m] treatment whenever a profile carries it. */ -export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = ['CLAUDE_CODE_SUBAGENT_MODEL'] as const; +export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', +] as const; /** Every model env key the [1m] preference is applied to. */ export const EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index a65af8b1..c1ce474a 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -28,13 +28,16 @@ describe('Anthropic model env keys', () => { }); describe('extended-context model env keys', () => { - it('covers the Anthropic tiers plus the subagent model key', () => { + it('covers the Anthropic tiers plus the subagent and startup-default model keys', () => { expect([...EXTENDED_CONTEXT_MODEL_ENV_KEYS]).toEqual([ ...ANTHROPIC_MODEL_ENV_KEYS, 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', ]); expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + expect(isExtendedContextModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(true); expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(false); }); }); @@ -64,6 +67,7 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { const env = applyExtendedContextPreferenceToAnthropicModels( { ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', }, @@ -72,11 +76,21 @@ describe('applyExtendedContextPreferenceToAnthropicModels', () => { expect(env).toEqual({ ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', }); }); + it('suffixes the startup-default model key when the preference is on', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5' }, + true + ); + + expect(env).toEqual({ ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]' }); + }); + it('honors a caller compatibility predicate', () => { const env = applyExtendedContextPreferenceToAnthropicModels( { From 247c0336c7f91cb143531eeff97da748d4457092 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 9 Sep 2026 15:23:19 -0600 Subject: [PATCH 48/53] docs: refresh hardening inventory after #1727 --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 43bed65e..0a700dcd 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -779,7 +779,7 @@ }, { "file": "src/cliproxy/model-catalog.ts", - "loc": 884 + "loc": 895 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 385cea08..afa9598d 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -103,5 +103,5 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | -| `src/cliproxy/model-catalog.ts` | 884 | +| `src/cliproxy/model-catalog.ts` | 895 | From 8cb3451f8153af3be6b855f3651d925efeb4e1de Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 9 Sep 2026 15:24:08 -0600 Subject: [PATCH 49/53] docs: refresh hardening inventory after dev merge --- docs/reports/hardening-inventory.json | 2 +- docs/reports/hardening-inventory.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index b89917eb..545f40c6 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -779,7 +779,7 @@ }, { "file": "src/cliproxy/model-catalog.ts", - "loc": 884 + "loc": 895 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 95856df3..a8aa1818 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -103,5 +103,5 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | -| `src/cliproxy/model-catalog.ts` | 884 | +| `src/cliproxy/model-catalog.ts` | 895 | From 375a3468b8c5de1ad9af963db5abb285feaffc94 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 9 Sep 2026 15:38:22 -0600 Subject: [PATCH 50/53] fix(openrouter): use /api endpoint root to prevent duplicated /v1 (#1728) --- src/shared/provider-preset-catalog.ts | 2 +- .../unit/api/profile-writer-anthropic.test.ts | 4 +- tests/unit/api/provider-presets.test.ts | 45 +++++++++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index ab2db76d..64728ebc 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -53,7 +53,7 @@ export interface ProviderPresetDefinition { icon?: string; } -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; export const ORCAROUTER_BASE_URL = 'https://api.orcarouter.ai/v1'; /** diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 5a497d98..0416c321 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -134,7 +134,7 @@ describe('profile-writer Anthropic direct', () => { it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', - 'https://openrouter.ai/api/v1', + 'https://openrouter.ai/api', 'sk-or-testkey', { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } ); @@ -145,7 +145,7 @@ describe('profile-writer Anthropic direct', () => { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank - expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api/v1'); + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 69386dbe..d90ff2eb 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -1,6 +1,8 @@ -import { existsSync } from 'fs'; -import { resolve } from 'path'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; import { describe, expect, it } from 'bun:test'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; import { PROVIDER_PRESETS, getPresetById, @@ -113,9 +115,44 @@ describe('provider-presets', () => { expect(isValidPresetId('te')).toBe(true); }); - it('uses OpenRouter v1 as the OpenAI-compatible API root', () => { + it('uses OpenRouter /api (without /v1) as the Anthropic-compatible API root to avoid double /v1 404 in Claude Code (#1728)', () => { const preset = getPresetById('openrouter'); - expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1'); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api'); + const finalEndpoint = `${preset?.baseUrl}/v1/messages`; + expect(finalEndpoint).toBe('https://openrouter.ai/api/v1/messages'); + expect(finalEndpoint).not.toContain('/v1/v1/'); + }); + + it('generates an OpenRouter profile whose ANTHROPIC_BASE_URL does not duplicate /v1 (#1728)', () => { + const preset = getPresetById('openrouter'); + expect(preset).toBeDefined(); + const tempHome = mkdtempSync(join(tmpdir(), 'ccs-openrouter-tdd-')); + const prevCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + try { + const result = createApiProfile( + 'openrouter-tdd', + preset!.baseUrl, + 'sk-or-test-key', + { + default: preset!.defaultModel, + opus: preset!.defaultModel, + sonnet: preset!.defaultModel, + haiku: preset!.defaultModel, + } + ); + expect(result.success).toBe(true); + const settings = JSON.parse( + readFileSync(join(tempHome, '.ccs', 'openrouter-tdd.settings.json'), 'utf8') + ); + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(`${settings.env.ANTHROPIC_BASE_URL}/v1/messages`).toBe( + 'https://openrouter.ai/api/v1/messages' + ); + } finally { + process.env.CCS_HOME = prevCcsHome; + rmSync(tempHome, { recursive: true, force: true }); + } }); it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { From 283e8843ac624377bc6422a2e704724baf8a5e07 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 9 Sep 2026 15:41:42 -0600 Subject: [PATCH 51/53] test(openrouter): refine regression test using existing profile-writer harness (#1728) --- .../unit/api/profile-writer-anthropic.test.ts | 12 ++++-- tests/unit/api/provider-presets.test.ts | 38 +------------------ 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 0416c321..f25c016b 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { createApiProfile } from '../../../src/api/services/profile-writer'; +import { getPresetById } from '../../../src/api/services/provider-presets'; describe('profile-writer Anthropic direct', () => { let tempHome = ''; @@ -131,10 +132,14 @@ describe('profile-writer Anthropic direct', () => { expect(config.profile_targets?.['hf-target-claude']).toBeUndefined(); }); - it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior and resolves preset baseUrl without duplicating /v1 (#1728)', () => { + const preset = getPresetById('openrouter'); + expect(preset).toBeDefined(); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api'); + const result = createApiProfile( 'openrouter-test', - 'https://openrouter.ai/api', + preset!.baseUrl, 'sk-or-testkey', { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } ); @@ -144,8 +149,9 @@ describe('profile-writer Anthropic direct', () => { const settingsPath = path.join(tempHome, '.ccs', 'openrouter-test.settings.json'); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank + // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank and baseUrl without /v1 expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(`${settings.env.ANTHROPIC_BASE_URL}/v1/messages`).toBe('https://openrouter.ai/api/v1/messages'); expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index d90ff2eb..69bb4501 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -1,8 +1,6 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; import { describe, expect, it } from 'bun:test'; -import { createApiProfile } from '../../../src/api/services/profile-writer'; import { PROVIDER_PRESETS, getPresetById, @@ -123,38 +121,6 @@ describe('provider-presets', () => { expect(finalEndpoint).not.toContain('/v1/v1/'); }); - it('generates an OpenRouter profile whose ANTHROPIC_BASE_URL does not duplicate /v1 (#1728)', () => { - const preset = getPresetById('openrouter'); - expect(preset).toBeDefined(); - const tempHome = mkdtempSync(join(tmpdir(), 'ccs-openrouter-tdd-')); - const prevCcsHome = process.env.CCS_HOME; - process.env.CCS_HOME = tempHome; - try { - const result = createApiProfile( - 'openrouter-tdd', - preset!.baseUrl, - 'sk-or-test-key', - { - default: preset!.defaultModel, - opus: preset!.defaultModel, - sonnet: preset!.defaultModel, - haiku: preset!.defaultModel, - } - ); - expect(result.success).toBe(true); - const settings = JSON.parse( - readFileSync(join(tempHome, '.ccs', 'openrouter-tdd.settings.json'), 'utf8') - ); - expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); - expect(`${settings.env.ANTHROPIC_BASE_URL}/v1/messages`).toBe( - 'https://openrouter.ai/api/v1/messages' - ); - } finally { - process.env.CCS_HOME = prevCcsHome; - rmSync(tempHome, { recursive: true, force: true }); - } - }); - it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' From 478e3f3702acb1f9fcd81e6cc12fe3cd17d30864 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Sep 2026 21:53:39 +0000 Subject: [PATCH 52/53] chore(release): 8.9.0-dev.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 769194d7..de0e4bcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.9", + "version": "8.9.0-dev.10", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 46daba0c6b5151ca97861ce9dc2ffbcd5b584c3b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Sep 2026 13:59:45 +0000 Subject: [PATCH 53/53] chore(release): 8.9.0-dev.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de0e4bcb..8a2d66fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0-dev.10", + "version": "8.9.0-dev.11", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",