diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..fcef7c44 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -35,6 +35,7 @@ import { import type { RuntimeMonitorConfig } from '../../config/unified-config-types'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { getTierLockForProvider } from '../../config/schemas/quota'; export type ManagedQuotaProvider = 'agy' | 'claude' | 'codex' | 'gemini' | 'ghcp'; type ManagedQuotaResult = @@ -428,10 +429,20 @@ export async function findHealthyAccount( const accounts = getProviderAccounts(provider); + // When a tier is locked for this specific provider, restrict candidates to + // that tier only. Locks are per-provider so locking "agy" to "ultra" does + // NOT affect failover for "claude", "codex", "gemini", or "ghcp". + // This is intentionally strict: no cross-tier fallback while a lock is active, + // so the user's explicit tier choice is always honored. + const tierLock = getTierLockForProvider(config.quota_management?.manual, provider); + // Filter available accounts const available = accounts.filter( (a) => - !exclude.includes(a.id) && !isAccountPaused(provider, a.id) && !isOnCooldown(provider, a.id) + !exclude.includes(a.id) && + !isAccountPaused(provider, a.id) && + !isOnCooldown(provider, a.id) && + (tierLock === null || (a.tier || 'unknown') === tierLock) ); if (available.length === 0) return null; @@ -623,6 +634,28 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise( } } -async function fetchCliproxyAuthFiles(port?: number): Promise { +export async function fetchCliproxyAuthFiles( + port?: number +): Promise { try { const result = await fetchManagementJson<{ files?: CliproxyManagementAuthFile[] }>( '/v0/management/auth-files', @@ -367,6 +369,29 @@ export const __testExports = { }, }; +/** + * Build an auth_index → account email/id map from CLIProxy auth file metadata. + * + * Keys are stored as strings (String(auth_index)) so both numeric and string + * auth_index values resolve consistently via `map.get(String(auth_index))`. + * + * Entries missing either `auth_index` or `email` are silently skipped. + * + * @param authFiles Auth file records from /v0/management/auth-files + * @returns Map from String(auth_index) → email + */ +export function buildAuthIndexToAccountMap( + authFiles: CliproxyManagementAuthFile[] +): Map { + const map = new Map(); + for (const file of authFiles) { + if (file.auth_index === undefined || file.auth_index === null) continue; + if (!file.email || file.email.trim().length === 0) continue; + map.set(String(file.auth_index), file.email.trim()); + } + return map; +} + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts new file mode 100644 index 00000000..717aaff3 --- /dev/null +++ b/src/commands/bar/index.ts @@ -0,0 +1,47 @@ +/** + * `ccs bar` command dispatcher + * + * Mirrors the pattern in src/commands/docker/index.ts. + * Subcommands: launch (default), install, uninstall, version / --version. + */ + +export async function handleBarCommand(args: string[]): Promise { + const subcommand = args[0]; + + // --version / version are aliases for the version subcommand + if (subcommand === '--version' || subcommand === 'version') { + const { handleBarVersion } = await import('./version-subcommand'); + await handleBarVersion(); + return; + } + + const commandHandlers: Record Promise> = { + launch: async (subArgs) => { + const { handleBarLaunch } = await import('./launch-subcommand'); + await handleBarLaunch(subArgs); + }, + install: async (subArgs) => { + const { handleBarInstall } = await import('./install-subcommand'); + await handleBarInstall(subArgs); + }, + uninstall: async (subArgs) => { + const { handleBarUninstall } = await import('./uninstall-subcommand'); + await handleBarUninstall(subArgs); + }, + }; + + // Bare `ccs bar` → launch + if (!subcommand || subcommand === 'launch') { + await commandHandlers.launch(subcommand ? args.slice(1) : []); + return; + } + + const handler = commandHandlers[subcommand]; + if (!handler) { + console.error(`[X] Unknown bar subcommand: ${subcommand}`); + console.error('[i] Usage: ccs bar [launch|install|uninstall|--version]'); + return; + } + + await handler(args.slice(1)); +} diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts new file mode 100644 index 00000000..8f286932 --- /dev/null +++ b/src/commands/bar/install-subcommand.ts @@ -0,0 +1,451 @@ +/** + * `ccs bar install` — download the CCS Bar app from the floating + * `ccs-bar-latest` GitHub release tag and install to ~/Applications. + * + * Intentionally uses a FLOATING tag (not the exact CLI version) so the + * Swift app can be rebuilt and published independently. After install the + * handler calls GET /api/overview to verify version compatibility and warns + * (but does not hard-fail) on mismatch. + * + * Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getCcsDir } from '../../config/config-loader-facade'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Floating release tag — never pin to exact CLI semver. */ +const BAR_RELEASE_TAG = 'ccs-bar-latest'; +const BAR_APP_NAME = 'CCS Bar.app'; +const BAR_ASSET_NAME = 'CCS-Bar.app.zip'; +const BAR_GITHUB_REPO = 'kaitranntt/ccs'; + +/** + * Allowlist of hostnames from which we will accept asset downloads. + * GitHub releases redirect from github.com to objects.githubusercontent.com. + * + * TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file, + * wire SHA-256 verification here. The download URL is already validated for + * host+HTTPS as a v1 minimum guard. The verifier hook below is the intended + * extension point. + */ +const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray = [ + 'github.com', + 'objects.githubusercontent.com', +]; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ReleaseAssetResult { + downloadUrl: string; + version: string; +} + +export interface CompatResult { + version: string; + compatible: boolean; +} + +export interface InstallDeps { + /** + * Resolve the asset download URL + version string from a GitHub release tag. + * Production: calls GitHub API releases/tags/{tag}. + * Test: mock that returns a fake URL + version. + */ + fetchReleaseAsset: (tag: string, asset: string) => Promise; + /** + * Download the zip archive and extract the .app bundle into dest/. + * Production: uses undici to stream + extract (with redirect + status check). + */ + downloadAndExtract: (url: string, dest: string) => Promise; + /** + * Call GET {baseUrl}/api/overview and return { version, compatible }. + * compatible = server version major === installed app version major. + * Returns compatible:false (never true) when versions cannot be compared. + */ + verifyCompat: (baseUrl: string, installedVersion: string) => Promise; + /** Returns path to ~/.ccs (respects CCS_HOME). */ + getCcsDir: () => string; + /** Destination directory for the .app bundle (~/Applications by default). */ + getAppsDir: () => string; +} + +// --------------------------------------------------------------------------- +// Host allowlist validation (Finding #9) +// --------------------------------------------------------------------------- + +/** + * Validate that a download URL is https and its hostname is in the allowlist + * (exact match or *.githubusercontent.com wildcard). + * Throws a descriptive Error if validation fails. + */ +export function validateDownloadUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Download URL is not a valid URL: ${url}`); + } + + if (parsed.protocol !== 'https:') { + throw new Error(`Download URL must use HTTPS, got: ${parsed.protocol} for ${url}`); + } + + const host = parsed.hostname.toLowerCase(); + const allowed = + (DOWNLOAD_HOST_ALLOWLIST as string[]).includes(host) || host.endsWith('.githubusercontent.com'); + + if (!allowed) { + throw new Error( + `Download URL hostname "${host}" is not in the trusted allowlist ` + + `(${DOWNLOAD_HOST_ALLOWLIST.join(', ')}, *.githubusercontent.com). ` + + `Refusing to download from untrusted host.` + ); + } +} + +// --------------------------------------------------------------------------- +// Production implementation helpers +// --------------------------------------------------------------------------- + +async function defaultFetchReleaseAsset(tag: string, asset: string): Promise { + const { request } = await import('undici'); + const apiUrl = `https://api.github.com/repos/${BAR_GITHUB_REPO}/releases/tags/${tag}`; + const { statusCode, body } = await request(apiUrl, { + headers: { + 'User-Agent': 'ccs-cli', + Accept: 'application/vnd.github+json', + }, + }); + + if (statusCode !== 200) { + throw new Error(`GitHub API returned ${statusCode} for tag ${tag}`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const release = (await body.json()) as any; + const tagName: string = release.tag_name ?? tag; + // Strip leading 'v' for the version pin file. + const version = tagName.replace(/^v/, ''); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset); + if (!found) { + throw new Error(`Asset "${asset}" not found in release ${tag}`); + } + + return { downloadUrl: found.browser_download_url as string, version }; +} + +/** + * Download a zip from `url` (following up to 5 GitHub 302 redirects, re-validating + * each hop's Location hostname) and extract its contents into `dest`. + * + * Fixes applied: + * - Fix #6: maxRedirections:0 + manual redirect following so every hop's Location + * header is passed through validateDownloadUrl before following it. + * The previous maxRedirections:5 let undici follow redirects to ANY host unchecked. + * - Fix #14: list zip entries with `unzip -l` before extracting; reject the archive + * if any entry path contains ".." or starts with "/" (zip-slip guard). + * - Finding #11: check `statusCode` and throw a descriptive error before streaming. + * - Finding #9: validate host+HTTPS before making the first request. + */ +async function defaultDownloadAndExtract(url: string, dest: string): Promise { + const { request } = await import('undici'); + const { createWriteStream, mkdirSync } = fs; + const { promisify } = await import('util'); + const { pipeline } = await import('stream'); + const streamPipeline = promisify(pipeline); + const { execFile } = await import('child_process'); + const execFileAsync = promisify(execFile); + + // Validate initial URL (Finding #9) + validateDownloadUrl(url); + + mkdirSync(dest, { recursive: true }); + + // Fix #6: follow redirects manually so each hop is re-validated. + const MAX_REDIRECTS = 5; + let currentUrl = url; + let redirectsFollowed = 0; + + while (true) { + const { statusCode, headers, body } = await request(currentUrl, { + maxRedirections: 0, // disable undici's auto-follow; we follow manually + }); + + if (statusCode >= 300 && statusCode < 400) { + const location = Array.isArray(headers['location']) + ? headers['location'][0] + : headers['location']; + + if (!location) { + throw new Error(`Redirect (HTTP ${statusCode}) from ${currentUrl} has no Location header`); + } + + // Resolve relative redirects against the current URL + const resolved = new URL(location, currentUrl).toString(); + + // Re-validate the redirect target — this is the key fix for #6 + validateDownloadUrl(resolved); + + if (redirectsFollowed >= MAX_REDIRECTS) { + throw new Error(`Too many redirects (>${MAX_REDIRECTS}) while downloading ${url}`); + } + + // Drain the body to free the socket before following the redirect + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (body as any).dump?.(); + currentUrl = resolved; + redirectsFollowed++; + continue; + } + + if (statusCode !== 200) { + throw new Error(`Download failed: HTTP ${statusCode} for ${currentUrl}`); + } + + const tmpZip = path.join(os.tmpdir(), `ccs-bar-${Date.now()}.zip`); + + // Stream body to tmpZip + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await streamPipeline(body as any, createWriteStream(tmpZip)); + + // Fix #14: zip-slip guard — inspect entries before extraction. + // `unzip -l` lists entries in a machine-readable format; we scan for ".." or + // absolute paths that would escape the destination directory. + try { + const { stdout: listing } = await execFileAsync('unzip', ['-l', tmpZip]); + const lines = listing.split('\n'); + for (const line of lines) { + // Entry lines look like: "