mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
051805074e
* fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names (#515) * fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. Model names in catalog, base config, and user settings are migrated to upstream claude-* names. Auto-migration in env-builder rewrites existing user settings on load and persists the change. Closes #513 * fix: address code review feedback — sync UI layer and add migration tests - Sync UI isNativeGeminiModel() with backend (remove gemini-claude- exclusion) - Update UI model catalog agy entries from gemini-claude-* to claude-* - Update CI/CD workflow and code-reviewer default model names - Add unit tests for migrateDeprecatedModelNames() logic * fix(hooks): isolate image type check before error-prone processing (#514) * fix(hooks): isolate image type check before error-prone processing Restructure processHook() into two phases so non-image Read calls never see hook error messages. Phase 1 defensively checks tool name and file extension, exiting 0 silently on any failure. Phase 2 only runs for confirmed image/PDF files where errors are relevant. Closes #511 * fix(hooks): sync image analyzer hook file on every profile launch Add installImageAnalyzerHook() call to cliproxy executor, matching the existing installWebSearchHook() pattern. This ensures the .cjs file in ~/.ccs/hooks/ gets refreshed from the npm package on every launch, so users receive hook updates after npm update. * chore(release): 7.41.0-dev.1 [skip ci] * fix(cliproxy): add fork:true for Claude model aliases in config generator (#523) Config generator now outputs fork:true for Claude model alias entries, ensuring both upstream (claude-*) and aliased (gemini-claude-*) model names appear in /v1/models listings. Also preserves fork flag when parsing user-added aliases during config regeneration. Bumps config version to v7 to trigger regeneration on next ccs doctor. Closes #522 * chore(release): 7.41.0-dev.2 [skip ci] * feat(cliproxy): add account safety guards to prevent Google account bans (#516) * feat(cliproxy): add account safety guards to prevent Google account bans Implements cross-provider isolation to prevent Google from flagging concurrent OAuth usage across different client IDs (ref: #509, #512). Three pillars: 1. Auto-pause enforcement at session launch — conflicting accounts in other Google OAuth providers are paused so CLIProxyAPI can't use them, restored on session exit with crash recovery via auto-paused.json 2. Ban/disable detection — error responses matching Google ban patterns auto-pause the affected account to prevent further damage 3. Cross-provider conflict warnings during OAuth registration Key design decisions: - PID-based session tracking for crash recovery (dead PID = restore) - Timestamp comparison prevents restoring ban-paused accounts on exit - Schema validation on auto-paused.json prevents corrupted state - Falls back to warn-only when another session is managing isolation * fix(cliproxy): address code review feedback (attempt 1/5) - Re-read auto-paused.json before write in enforceProviderIsolation to reduce concurrent write race window - Use actual email from registry for display instead of raw accountId - Export maskEmail for testability - Add 27 unit tests covering ban detection, email masking, cross-provider duplicate detection, enforcement lifecycle, crash recovery, and timestamp-guarded restore * fix(cliproxy): address remaining review feedback (attempt 2/5) - Add handleBanDetection test verifying account pause on ban error - Add warnCrossProviderDuplicates tests (true/false/non-Google) - Document PID reuse limitation in isPidAlive JSDoc comment * chore(release): 7.41.0-dev.3 [skip ci] * feat(cliproxy): runtime quota monitoring during active sessions (#529) * feat(cliproxy): add runtime quota monitoring during active sessions Adds adaptive background quota polling to detect and respond to quota exhaustion during active CLIProxy sessions. Prevents rate-limit-driven account bans by auto-cooling exhausted accounts and switching defaults. - Adaptive polling: 300s normal, 60s at 20% threshold, stops at 0% - Stderr warnings at 20%, boxed exhaustion alerts at 0% - Cooldown + default switch on exhaustion (existing patterns) - Configurable via quota_management.runtime_monitor in config.yaml - Timer.unref() prevents blocking process exit - monitorStopped guard for in-flight poll safety Closes #524 * fix: address code review feedback (attempt 1/5) - M1: Round quotaPercent display with Math.round() to avoid ugly floats - M2: Rename exhaust_threshold -> exhaustion_threshold for consistency with existing auto.exhaustion_threshold config field - M3: Replace async not.toThrow() with direct await assertion pattern * fix: address code review feedback (attempt 2/5) - Remove .claude/agent-memory/ from tracking and add to .gitignore - Unify cooldown_minutes default to 5 (was 10 in runtime_monitor, 5 in auto) - Add threshold validation in startQuotaMonitor (warn > exhaustion) - Document intentional post-switch monitoring gap in code comment * chore(release): 7.41.0-dev.4 [skip ci] * fix(cliproxy): mask email in ban detection and fix JSDoc default - Use maskEmail() in handleBanDetection output for consistency - Fix cooldown_minutes JSDoc: default is 5, not 10 * chore(release): 7.41.0-dev.5 [skip ci] * fix(cliproxy): address all review feedback (Low + informational) - Add sync constraint comment on process.exit handler (executor) - Add TOCTOU race acceptability comment (account-safety) - Mask email in handleQuotaExhaustion reason string - Use realistic exhaustion_threshold (5) in test configs * chore(release): 7.41.0-dev.6 [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
/**
|
|
* Session Bridge - Integration with session tracking and proxy detection
|
|
*
|
|
* Handles:
|
|
* - Session registration and unregistration
|
|
* - Proxy detection and version checking
|
|
* - Orphaned proxy reclamation
|
|
* - Startup lock coordination
|
|
*/
|
|
|
|
import { ChildProcess } from 'child_process';
|
|
import { info, warn } from '../../utils/ui';
|
|
import { getInstalledCliproxyVersion } from '../binary-manager';
|
|
import { CLIProxyBackend } from '../types';
|
|
import {
|
|
cleanupOrphanedSessions,
|
|
registerSession,
|
|
unregisterSession,
|
|
stopProxy,
|
|
} from '../session-tracker';
|
|
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector';
|
|
import { withStartupLock } from '../startup-lock';
|
|
import { killProcessOnPort } from '../../utils/platform-commands';
|
|
import { stopQuotaMonitor } from '../quota-manager';
|
|
|
|
export interface ProxySessionResult {
|
|
sessionId?: string;
|
|
proxy?: ChildProcess;
|
|
shouldSpawn: boolean;
|
|
}
|
|
|
|
/**
|
|
* Check for existing proxy and handle version mismatch, or determine if new spawn needed
|
|
*/
|
|
export async function checkOrJoinProxy(
|
|
port: number,
|
|
timeout: number,
|
|
verbose: boolean
|
|
): Promise<ProxySessionResult> {
|
|
const log = (msg: string) => {
|
|
if (verbose) {
|
|
console.error(`[cliproxy] ${msg}`);
|
|
}
|
|
};
|
|
|
|
// Cleanup orphaned sessions before detection
|
|
cleanupOrphanedSessions(port);
|
|
|
|
let sessionId: string | undefined;
|
|
let shouldSpawn = false;
|
|
|
|
// Use startup lock to coordinate with other CCS processes
|
|
await withStartupLock(async () => {
|
|
// Detect running proxy using multiple methods (HTTP, session-lock, port-process)
|
|
let proxyStatus = await detectRunningProxy(port);
|
|
log(`Proxy detection: ${JSON.stringify(proxyStatus)}`);
|
|
|
|
// Check for version mismatch - restart proxy if installed version differs from running
|
|
if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) {
|
|
const installedVersion = getInstalledCliproxyVersion();
|
|
if (installedVersion !== proxyStatus.version) {
|
|
console.log(
|
|
warn(
|
|
`Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...`
|
|
)
|
|
);
|
|
log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`);
|
|
const stopResult = await stopProxy(port);
|
|
if (stopResult.stopped) {
|
|
log(`Stopped outdated proxy successfully`);
|
|
} else {
|
|
log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`);
|
|
}
|
|
// Wait for port to be released
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
// Re-detect proxy status (should now be not running)
|
|
proxyStatus = await detectRunningProxy(port);
|
|
log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`);
|
|
}
|
|
}
|
|
|
|
if (proxyStatus.running && proxyStatus.verified) {
|
|
// Healthy proxy found - join it
|
|
if (proxyStatus.pid) {
|
|
sessionId = reclaimOrphanedProxy(port, proxyStatus.pid, verbose) ?? undefined;
|
|
}
|
|
if (sessionId) {
|
|
console.log(info(`Joined existing CLIProxy on port ${port} (${proxyStatus.method})`));
|
|
} else {
|
|
// Failed to register session - proxy is running but we can't track it
|
|
console.log(info(`Using existing CLIProxy on port ${port} (session tracking unavailable)`));
|
|
log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`);
|
|
}
|
|
return; // Exit lock early, skip spawning
|
|
}
|
|
|
|
if (proxyStatus.running && !proxyStatus.verified) {
|
|
// Proxy detected but not ready yet (another process is starting it)
|
|
log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`);
|
|
const becameHealthy = await waitForProxyHealthy(port, timeout);
|
|
if (becameHealthy) {
|
|
if (proxyStatus.pid) {
|
|
sessionId = reclaimOrphanedProxy(port, proxyStatus.pid, verbose) ?? undefined;
|
|
}
|
|
console.log(info(`Joined CLIProxy after startup wait`));
|
|
return; // Exit lock early
|
|
}
|
|
// Proxy didn't become healthy - kill and respawn
|
|
if (proxyStatus.pid) {
|
|
log(`Proxy PID ${proxyStatus.pid} not responding, killing...`);
|
|
killProcessOnPort(port, verbose);
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
}
|
|
|
|
if (proxyStatus.blocked && proxyStatus.blocker) {
|
|
// Port blocked by non-CLIProxy process
|
|
// Last resort: try HTTP health check (handles Windows PID-XXXXX case)
|
|
const isActuallyOurs = await waitForProxyHealthy(port, 1000);
|
|
if (isActuallyOurs) {
|
|
sessionId = reclaimOrphanedProxy(port, proxyStatus.blocker.pid, verbose) ?? undefined;
|
|
console.log(info(`Reclaimed CLIProxy with unrecognized process name`));
|
|
return;
|
|
}
|
|
|
|
// Truly blocked by another application
|
|
const { getPortCheckCommand } = await import('../../utils/platform-commands');
|
|
console.error('');
|
|
console.error(
|
|
warn(
|
|
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
|
|
)
|
|
);
|
|
console.error('');
|
|
console.error('To fix this, close the blocking application or run:');
|
|
console.error(` ${getPortCheckCommand(port)}`);
|
|
console.error('');
|
|
throw new Error(`Port ${port} is in use by another application`);
|
|
}
|
|
|
|
// No proxy found - need to spawn
|
|
shouldSpawn = true;
|
|
});
|
|
|
|
return { sessionId, shouldSpawn };
|
|
}
|
|
|
|
/**
|
|
* Register a new proxy session after spawning
|
|
*/
|
|
export function registerProxySession(
|
|
port: number,
|
|
pid: number,
|
|
backend: CLIProxyBackend,
|
|
verbose: boolean
|
|
): string {
|
|
const installedVersion = getInstalledCliproxyVersion();
|
|
const sessionId = registerSession(port, pid, installedVersion, backend);
|
|
|
|
if (verbose) {
|
|
console.error(
|
|
`[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})`
|
|
);
|
|
}
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
/**
|
|
* Setup cleanup handlers for session unregistration
|
|
*/
|
|
export function setupCleanupHandlers(
|
|
claude: ChildProcess,
|
|
sessionId: string | undefined,
|
|
sessionPort: number,
|
|
codexReasoningProxy: unknown,
|
|
toolSanitizationProxy: unknown,
|
|
httpsTunnel: unknown,
|
|
verbose: boolean
|
|
): void {
|
|
const log = (msg: string) => {
|
|
if (verbose) {
|
|
console.error(`[cliproxy] ${msg}`);
|
|
}
|
|
};
|
|
|
|
const cleanup = () => {
|
|
stopQuotaMonitor();
|
|
log('Parent signal received, cleaning up');
|
|
|
|
if (
|
|
codexReasoningProxy &&
|
|
typeof codexReasoningProxy === 'object' &&
|
|
'stop' in codexReasoningProxy
|
|
) {
|
|
(codexReasoningProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (
|
|
toolSanitizationProxy &&
|
|
typeof toolSanitizationProxy === 'object' &&
|
|
'stop' in toolSanitizationProxy
|
|
) {
|
|
(toolSanitizationProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
|
(httpsTunnel as { stop: () => void }).stop();
|
|
}
|
|
|
|
// Unregister session, proxy keeps running (local mode only)
|
|
if (sessionId) {
|
|
unregisterSession(sessionId, sessionPort);
|
|
}
|
|
claude.kill('SIGTERM');
|
|
};
|
|
|
|
claude.on('exit', (code, signal) => {
|
|
stopQuotaMonitor();
|
|
log(`Claude exited: code=${code}, signal=${signal}`);
|
|
|
|
if (
|
|
codexReasoningProxy &&
|
|
typeof codexReasoningProxy === 'object' &&
|
|
'stop' in codexReasoningProxy
|
|
) {
|
|
(codexReasoningProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (
|
|
toolSanitizationProxy &&
|
|
typeof toolSanitizationProxy === 'object' &&
|
|
'stop' in toolSanitizationProxy
|
|
) {
|
|
(toolSanitizationProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
|
(httpsTunnel as { stop: () => void }).stop();
|
|
}
|
|
|
|
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
|
if (sessionId) {
|
|
unregisterSession(sessionId, sessionPort);
|
|
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
|
}
|
|
|
|
if (signal) {
|
|
process.kill(process.pid, signal as NodeJS.Signals);
|
|
} else {
|
|
process.exit(code || 0);
|
|
}
|
|
});
|
|
|
|
claude.on('error', (error) => {
|
|
stopQuotaMonitor();
|
|
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
|
|
|
if (
|
|
codexReasoningProxy &&
|
|
typeof codexReasoningProxy === 'object' &&
|
|
'stop' in codexReasoningProxy
|
|
) {
|
|
(codexReasoningProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (
|
|
toolSanitizationProxy &&
|
|
typeof toolSanitizationProxy === 'object' &&
|
|
'stop' in toolSanitizationProxy
|
|
) {
|
|
(toolSanitizationProxy as { stop: () => void }).stop();
|
|
}
|
|
|
|
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
|
|
(httpsTunnel as { stop: () => void }).stop();
|
|
}
|
|
|
|
// Unregister session, proxy keeps running (local mode only)
|
|
if (sessionId) {
|
|
unregisterSession(sessionId, sessionPort);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
|
|
process.once('SIGTERM', cleanup);
|
|
process.once('SIGINT', cleanup);
|
|
}
|