Files
ccs/src/cliproxy/executor/session-bridge.ts
T
Kai (Tam Nhu) TranGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
051805074e feat: account safety, quota monitoring, and stability fixes (#530)
* 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>
2026-02-12 00:48:29 +07:00

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);
}