mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
Merge pull request #343 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.21.0",
|
||||
"version": "7.21.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -39,7 +39,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.0.0",
|
||||
"node": ">=18.0.0",
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.21",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* Account storage: ~/.ccs/cliproxy/accounts.json
|
||||
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
|
||||
* Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -47,6 +48,8 @@ export interface AccountInfo {
|
||||
pausedAt?: string;
|
||||
/** Account tier: free or paid (Pro/Ultra combined) */
|
||||
tier?: AccountTier;
|
||||
/** GCP Project ID (Antigravity only) - read-only, fetched from auth token */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/** Provider accounts configuration */
|
||||
@@ -135,6 +138,19 @@ export function getAccountsRegistryPath(): string {
|
||||
return path.join(getCliproxyDir(), 'accounts.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to paused tokens directory
|
||||
* Paused tokens are moved here so CLIProxyAPI won't discover them
|
||||
*
|
||||
* Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/)
|
||||
* because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans
|
||||
* all subdirectories of auth/. A sibling directory is completely outside
|
||||
* CLIProxyAPI's scan path, preventing token refresh loops.
|
||||
*/
|
||||
export function getPausedDir(): string {
|
||||
return path.join(getCliproxyDir(), 'auth-paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
*/
|
||||
@@ -176,10 +192,12 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
/**
|
||||
* Sync registry with actual token files
|
||||
* Removes stale entries where token file no longer exists
|
||||
* For paused accounts, checks both auth/ and paused/ directories
|
||||
* Called automatically when loading accounts
|
||||
*/
|
||||
function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
let modified = false;
|
||||
|
||||
for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) {
|
||||
@@ -189,7 +207,14 @@ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
|
||||
|
||||
for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) {
|
||||
const tokenPath = path.join(authDir, meta.tokenFile);
|
||||
if (!fs.existsSync(tokenPath)) {
|
||||
const pausedPath = path.join(pausedDir, meta.tokenFile);
|
||||
|
||||
// For paused accounts, check paused dir; for active accounts, check auth dir
|
||||
const expectedPath = meta.paused ? pausedPath : tokenPath;
|
||||
// Also accept if file exists in either location (handles edge cases)
|
||||
const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath);
|
||||
|
||||
if (!fs.existsSync(expectedPath) && !existsAnywhere) {
|
||||
staleIds.push(accountId);
|
||||
}
|
||||
}
|
||||
@@ -293,7 +318,8 @@ export function registerAccount(
|
||||
provider: CLIProxyProvider,
|
||||
tokenFile: string,
|
||||
email?: string,
|
||||
nickname?: string
|
||||
nickname?: string,
|
||||
projectId?: string
|
||||
): AccountInfo {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
@@ -350,7 +376,7 @@ export function registerAccount(
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
// Create or update account
|
||||
providerAccounts.accounts[accountId] = {
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
@@ -358,6 +384,13 @@ export function registerAccount(
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Include projectId for Antigravity accounts
|
||||
if (provider === 'agy' && projectId) {
|
||||
accountMeta.projectId = projectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
|
||||
// Set as default if first account
|
||||
if (isFirstAccount) {
|
||||
providerAccounts.default = accountId;
|
||||
@@ -374,6 +407,7 @@ export function registerAccount(
|
||||
tokenFile,
|
||||
createdAt: providerAccounts.accounts[accountId].createdAt,
|
||||
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
|
||||
projectId: providerAccounts.accounts[accountId].projectId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +429,7 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
|
||||
|
||||
/**
|
||||
* Pause an account (skip in quota rotation)
|
||||
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it
|
||||
*/
|
||||
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
@@ -404,6 +439,32 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
|
||||
// Skip if already paused (idempotent)
|
||||
if (accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
const tokenPath = path.join(authDir, accountMeta.tokenFile);
|
||||
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
|
||||
|
||||
// Move token file to paused directory (if it exists in auth dir)
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
// Create paused directory if it doesn't exist
|
||||
if (!fs.existsSync(pausedDir)) {
|
||||
fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.renameSync(tokenPath, pausedPath);
|
||||
} catch {
|
||||
// File operation failed, but continue with registry update
|
||||
// syncRegistryWithTokenFiles() will handle recovery on next load
|
||||
}
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = true;
|
||||
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
@@ -412,6 +473,7 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
|
||||
/**
|
||||
* Resume a paused account
|
||||
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
|
||||
*/
|
||||
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
@@ -421,6 +483,28 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
|
||||
// Skip if already active (idempotent)
|
||||
if (!accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
const tokenPath = path.join(authDir, accountMeta.tokenFile);
|
||||
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
|
||||
|
||||
// Move token file back from paused directory (if it exists in paused dir)
|
||||
if (fs.existsSync(pausedPath)) {
|
||||
try {
|
||||
fs.renameSync(pausedPath, tokenPath);
|
||||
} catch {
|
||||
// File operation failed, but continue with registry update
|
||||
// syncRegistryWithTokenFiles() will handle recovery on next load
|
||||
}
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = false;
|
||||
providerAccounts.accounts[accountId].pausedAt = undefined;
|
||||
saveAccountsRegistry(registry);
|
||||
@@ -474,11 +558,12 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get token file to delete
|
||||
// Get token file to delete (check both auth and paused directories)
|
||||
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
|
||||
const tokenPath = path.join(getAuthDir(), tokenFile);
|
||||
const pausedPath = path.join(getPausedDir(), tokenFile);
|
||||
|
||||
// Delete token file
|
||||
// Delete token file from auth directory
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tokenPath);
|
||||
@@ -487,6 +572,15 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete from paused directory if it exists there
|
||||
if (fs.existsSync(pausedPath)) {
|
||||
try {
|
||||
fs.unlinkSync(pausedPath);
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete providerAccounts.accounts[accountId];
|
||||
|
||||
@@ -547,6 +641,7 @@ export function touchAccount(provider: CLIProxyProvider, accountId: string): voi
|
||||
|
||||
/**
|
||||
* Get token file path for an account
|
||||
* Returns path in paused/ dir if account is paused, otherwise auth/
|
||||
*/
|
||||
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
|
||||
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
|
||||
@@ -555,7 +650,9 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(getAuthDir(), account.tokenFile);
|
||||
// Return path from paused directory if account is paused
|
||||
const baseDir = account.paused ? getPausedDir() : getAuthDir();
|
||||
return path.join(baseDir, account.tokenFile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -629,6 +726,20 @@ export function discoverExistingAccounts(): void {
|
||||
// Skip if token file already registered (under any accountId)
|
||||
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
|
||||
if (existingTokenFiles.includes(file)) {
|
||||
// Token file exists - check if we need to update projectId for agy accounts
|
||||
const projectIdValue =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
if (provider === 'agy' && projectIdValue) {
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, meta]) => meta.tokenFile === file
|
||||
);
|
||||
// Update if missing or changed
|
||||
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
|
||||
existingEntry[1].projectId = projectIdValue;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -671,13 +782,24 @@ export function discoverExistingAccounts(): void {
|
||||
// Register account with auto-generated nickname
|
||||
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
|
||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
||||
providerAccounts.accounts[accountId] = {
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: generateNickname(email),
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: lastModified.toISOString(),
|
||||
};
|
||||
|
||||
// Read project_id for Antigravity accounts (read-only field from auth token)
|
||||
const discoveredProjectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
if (provider === 'agy' && discoveredProjectId) {
|
||||
accountMeta.projectId = discoveredProjectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
continue;
|
||||
@@ -693,7 +815,7 @@ export function discoverExistingAccounts(): void {
|
||||
if (!freshRegistry.providers[prov]) {
|
||||
freshRegistry.providers[prov] = discovered;
|
||||
} else {
|
||||
// Merge accounts, preferring fresh registry's existing entries
|
||||
// Merge accounts, preferring fresh registry's existing entries but updating projectId
|
||||
const freshProviderAccounts = freshRegistry.providers[prov];
|
||||
if (!freshProviderAccounts) continue;
|
||||
for (const [id, meta] of Object.entries(discovered.accounts)) {
|
||||
@@ -703,6 +825,9 @@ export function discoverExistingAccounts(): void {
|
||||
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
|
||||
freshProviderAccounts.default = id;
|
||||
}
|
||||
} else if (meta.projectId && !freshProviderAccounts.accounts[id].projectId) {
|
||||
// Update existing account with projectId if discovered from auth file
|
||||
freshProviderAccounts.accounts[id].projectId = meta.projectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,8 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||
export function registerAccountFromToken(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string,
|
||||
nickname?: string
|
||||
nickname?: string,
|
||||
verbose = false
|
||||
): import('../account-manager').AccountInfo | null {
|
||||
const { registerAccount, generateNickname } = require('../account-manager');
|
||||
try {
|
||||
@@ -229,13 +230,65 @@ export function registerAccountFromToken(
|
||||
const content = fs.readFileSync(tokenPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
|
||||
return registerAccount(provider, newestFile, email, nickname || generateNickname(email));
|
||||
const account = registerAccount(
|
||||
provider,
|
||||
newestFile,
|
||||
email,
|
||||
nickname || generateNickname(email),
|
||||
projectId
|
||||
);
|
||||
|
||||
// Upload token to remote server if configured (async, don't block)
|
||||
uploadTokenToRemoteAsync(tokenPath, verbose);
|
||||
|
||||
return account;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload token to remote server asynchronously (fire and forget).
|
||||
* Only runs if remote mode is enabled. Logs success/failure via uploadTokenToRemote.
|
||||
* Does not block the OAuth flow - local token is always valid regardless of upload result.
|
||||
*
|
||||
* @param tokenPath - Path to the token file
|
||||
* @param verbose - Enable verbose logging for upload progress
|
||||
*/
|
||||
function uploadTokenToRemoteAsync(tokenPath: string, verbose: boolean): void {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
import('../remote-token-uploader')
|
||||
.then(({ uploadTokenToRemote, isRemoteUploadEnabled }) => {
|
||||
if (isRemoteUploadEnabled()) {
|
||||
// uploadTokenToRemote handles its own success/failure logging
|
||||
// On failure, show additional warning so users know local token is still valid
|
||||
uploadTokenToRemote(tokenPath, verbose)
|
||||
.then((success) => {
|
||||
if (!success) {
|
||||
console.error(
|
||||
'\n[!] Remote upload failed - token saved locally only. Run "ccs tokens upload" to retry.'
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Unexpected error (not handled by uploadTokenToRemote)
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[token-manager] Unexpected upload error: ${message}`);
|
||||
console.error(
|
||||
'[!] Token saved locally. Run "ccs tokens upload" to sync to remote server.'
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Module load failed - log for debugging
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[token-manager] Failed to load remote-token-uploader: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display auth status for all providers
|
||||
*/
|
||||
|
||||
@@ -60,6 +60,7 @@ import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '.
|
||||
import { withStartupLock } from './startup-lock';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { preflightCheck } from './quota-manager';
|
||||
import { HttpsTunnelProxy } from './https-tunnel-proxy';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -693,17 +694,56 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// For HTTPS remote, we need a local HTTP tunnel since Claude Code doesn't support
|
||||
// HTTPS in ANTHROPIC_BASE_URL directly (undici limitation)
|
||||
let httpsTunnel: HttpsTunnelProxy | null = null;
|
||||
let tunnelPort: number | null = null;
|
||||
|
||||
if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) {
|
||||
try {
|
||||
httpsTunnel = new HttpsTunnelProxy({
|
||||
remoteHost: proxyConfig.host,
|
||||
remotePort: proxyConfig.port,
|
||||
authToken: proxyConfig.authToken,
|
||||
verbose,
|
||||
allowSelfSigned: proxyConfig.allowSelfSigned ?? false,
|
||||
});
|
||||
tunnelPort = await httpsTunnel.start();
|
||||
log(
|
||||
`HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}`
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`));
|
||||
throw new Error(`HTTPS tunnel startup failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build env vars - use tunnel port for HTTPS remote, direct URL otherwise
|
||||
const envVars = useRemoteProxy
|
||||
? getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
},
|
||||
cfg.customSettingsPath
|
||||
)
|
||||
? httpsTunnel && tunnelPort
|
||||
? // HTTPS remote via local tunnel - use HTTP to tunnel
|
||||
getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: tunnelPort,
|
||||
protocol: 'http', // Tunnel speaks HTTP locally
|
||||
authToken: proxyConfig.authToken,
|
||||
},
|
||||
cfg.customSettingsPath
|
||||
)
|
||||
: // HTTP remote - direct connection
|
||||
getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
},
|
||||
cfg.customSettingsPath
|
||||
)
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
|
||||
|
||||
// Codex-only: inject OpenAI reasoning effort based on tier model mapping.
|
||||
@@ -722,6 +762,9 @@ export async function execClaudeWithCLIProxy(
|
||||
const traceEnabled =
|
||||
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
|
||||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
||||
// For remote proxy mode, strip /api/provider/codex prefix from paths
|
||||
// because remote CLIProxyAPI uses root paths (/v1/messages), not provider-prefixed
|
||||
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
||||
codexReasoningProxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL,
|
||||
verbose,
|
||||
@@ -735,6 +778,7 @@ export async function execClaudeWithCLIProxy(
|
||||
sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
},
|
||||
stripPathPrefix,
|
||||
});
|
||||
codexReasoningPort = await codexReasoningProxy.start();
|
||||
log(
|
||||
@@ -837,6 +881,10 @@ export async function execClaudeWithCLIProxy(
|
||||
codexReasoningProxy.stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel) {
|
||||
httpsTunnel.stop();
|
||||
}
|
||||
|
||||
// Unregister this session (proxy keeps running for persistence) - only for local mode
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
@@ -857,6 +905,10 @@ export async function execClaudeWithCLIProxy(
|
||||
codexReasoningProxy.stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel) {
|
||||
httpsTunnel.stop();
|
||||
}
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
@@ -872,6 +924,10 @@ export async function execClaudeWithCLIProxy(
|
||||
codexReasoningProxy.stop();
|
||||
}
|
||||
|
||||
if (httpsTunnel) {
|
||||
httpsTunnel.stop();
|
||||
}
|
||||
|
||||
// Unregister session, proxy keeps running (local mode only)
|
||||
if (sessionId) {
|
||||
unregisterSession(sessionId, sessionPort);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { URL } from 'url';
|
||||
|
||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||
@@ -17,6 +18,12 @@ export interface CodexReasoningProxyConfig {
|
||||
modelMap: CodexReasoningModelMap;
|
||||
defaultEffort?: CodexReasoningEffort;
|
||||
traceFilePath?: string;
|
||||
/**
|
||||
* Path prefix to strip from incoming requests before forwarding to upstream.
|
||||
* Used for remote proxy mode where upstream expects /v1/messages, not /api/provider/codex/v1/messages.
|
||||
* Example: '/api/provider/codex' will transform '/api/provider/codex/v1/messages' to '/v1/messages'
|
||||
*/
|
||||
stripPathPrefix?: string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
@@ -108,7 +115,7 @@ export class CodexReasoningProxy {
|
||||
'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath'
|
||||
>
|
||||
> &
|
||||
Pick<CodexReasoningProxyConfig, 'modelMap'>;
|
||||
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||
private readonly modelEffort: Map<string, CodexReasoningEffort>;
|
||||
private readonly recent: Array<{
|
||||
at: string;
|
||||
@@ -127,6 +134,7 @@ export class CodexReasoningProxy {
|
||||
modelMap: config.modelMap,
|
||||
defaultEffort: config.defaultEffort ?? 'medium',
|
||||
traceFilePath: config.traceFilePath ?? '',
|
||||
stripPathPrefix: config.stripPathPrefix,
|
||||
};
|
||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
||||
}
|
||||
@@ -221,7 +229,26 @@ export class CodexReasoningProxy {
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const method = req.method || 'GET';
|
||||
const requestPath = req.url || '/';
|
||||
let requestPath = req.url || '/';
|
||||
|
||||
// Strip path prefix if configured (for remote proxy mode)
|
||||
// e.g., '/api/provider/codex/v1/messages' → '/v1/messages'
|
||||
// Boundary check: only match complete path segments (not partial like /codex matching /codextra)
|
||||
if (
|
||||
this.config.stripPathPrefix &&
|
||||
requestPath.startsWith(this.config.stripPathPrefix) &&
|
||||
(requestPath.length === this.config.stripPathPrefix.length ||
|
||||
requestPath[this.config.stripPathPrefix.length] === '/')
|
||||
) {
|
||||
let stripped = requestPath.slice(this.config.stripPathPrefix.length);
|
||||
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
|
||||
stripped = stripped.replace(/^\/+/, '/') || '/';
|
||||
if (!stripped.startsWith('/')) {
|
||||
stripped = '/' + stripped;
|
||||
}
|
||||
requestPath = stripped;
|
||||
}
|
||||
|
||||
const upstreamBase = new URL(this.config.upstreamBaseUrl);
|
||||
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
|
||||
|
||||
@@ -332,13 +359,22 @@ export class CodexReasoningProxy {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate request function based on protocol.
|
||||
* Uses https.request for HTTPS URLs, http.request for HTTP.
|
||||
*/
|
||||
private getRequestFn(url: URL): typeof http.request | typeof https.request {
|
||||
return url.protocol === 'https:' ? https.request : http.request;
|
||||
}
|
||||
|
||||
private forwardRaw(
|
||||
originalReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
upstreamUrl: URL
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const upstreamReq = http.request(
|
||||
const requestFn = this.getRequestFn(upstreamUrl);
|
||||
const upstreamReq = requestFn(
|
||||
{
|
||||
protocol: upstreamUrl.protocol,
|
||||
hostname: upstreamUrl.hostname,
|
||||
@@ -370,7 +406,8 @@ export class CodexReasoningProxy {
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bodyString = JSON.stringify(body);
|
||||
const upstreamReq = http.request(
|
||||
const requestFn = this.getRequestFn(upstreamUrl);
|
||||
const upstreamReq = requestFn(
|
||||
{
|
||||
protocol: upstreamUrl.protocol,
|
||||
hostname: upstreamUrl.hostname,
|
||||
|
||||
@@ -739,7 +739,9 @@ export function getRemoteEnvVars(
|
||||
// Omit port suffix for standard web ports (80/443) for cleaner URLs
|
||||
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
|
||||
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
|
||||
const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
|
||||
// Remote CLIProxyAPI uses root path (e.g., /v1/messages), not /api/provider/{provider}/v1/messages
|
||||
// The /api/provider/ prefix is only for local CLIProxy instances
|
||||
const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* HTTPS Tunnel Proxy
|
||||
*
|
||||
* Local HTTP server that tunnels requests to a remote HTTPS CLIProxyAPI.
|
||||
* Required because Claude Code (via undici/node-fetch) doesn't support
|
||||
* HTTPS in ANTHROPIC_BASE_URL directly.
|
||||
*
|
||||
* Flow:
|
||||
* Claude CLI --HTTP--> Local Tunnel (port X) --HTTPS--> Remote CLIProxyAPI
|
||||
*
|
||||
* Note: Unlike CodexReasoningProxy, this tunnel does NOT buffer or limit response sizes.
|
||||
* Responses are streamed directly (pipe) which is appropriate for a transparent tunnel.
|
||||
* Socket-level timeouts handle hung connections; size limits are enforced by the remote server.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import type { Socket } from 'net';
|
||||
|
||||
export interface HttpsTunnelConfig {
|
||||
/** Remote server hostname */
|
||||
remoteHost: string;
|
||||
/** Remote server port (default: 443) */
|
||||
remotePort?: number;
|
||||
/** Auth token for remote server */
|
||||
authToken?: string;
|
||||
/** Request timeout in ms (default: 120000) */
|
||||
timeoutMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
/** Skip TLS certificate validation (for self-signed certs) */
|
||||
allowSelfSigned?: boolean;
|
||||
}
|
||||
|
||||
export class HttpsTunnelProxy {
|
||||
private server: http.Server | null = null;
|
||||
private port: number | null = null;
|
||||
private startingPromise: Promise<number> | null = null;
|
||||
private activeConnections = new Set<Socket>();
|
||||
private readonly config: Required<
|
||||
Pick<
|
||||
HttpsTunnelConfig,
|
||||
'remoteHost' | 'remotePort' | 'timeoutMs' | 'verbose' | 'allowSelfSigned'
|
||||
>
|
||||
> &
|
||||
Pick<HttpsTunnelConfig, 'authToken'>;
|
||||
|
||||
constructor(config: HttpsTunnelConfig) {
|
||||
// Validate hostname format (basic check for common issues)
|
||||
if (!config.remoteHost || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$/.test(config.remoteHost)) {
|
||||
if (
|
||||
config.remoteHost &&
|
||||
config.remoteHost.length === 1 &&
|
||||
/^[a-zA-Z0-9]$/.test(config.remoteHost)
|
||||
) {
|
||||
// Single character hostname is valid
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid remoteHost format: "${config.remoteHost}". ` +
|
||||
'Expected hostname without protocol (e.g., "api.example.com")'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.config = {
|
||||
remoteHost: config.remoteHost,
|
||||
remotePort: config.remotePort ?? 443,
|
||||
timeoutMs: config.timeoutMs ?? 120000,
|
||||
verbose: config.verbose ?? false,
|
||||
allowSelfSigned: config.allowSelfSigned ?? false,
|
||||
authToken: config.authToken,
|
||||
};
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
if (this.config.verbose) {
|
||||
console.error(`[https-tunnel] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<number> {
|
||||
// Already started
|
||||
if (this.server) return this.port ?? 0;
|
||||
|
||||
// Prevent race condition: if start() is already in progress, return the same promise
|
||||
if (this.startingPromise) return this.startingPromise;
|
||||
|
||||
this.startingPromise = new Promise((resolve, reject) => {
|
||||
this.server = http.createServer((req, res) => {
|
||||
void this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Track connections for proper cleanup
|
||||
this.server.on('connection', (socket: Socket) => {
|
||||
this.activeConnections.add(socket);
|
||||
socket.on('close', () => this.activeConnections.delete(socket));
|
||||
});
|
||||
|
||||
this.server.listen(0, '127.0.0.1', () => {
|
||||
const address = this.server?.address();
|
||||
this.port = typeof address === 'object' && address ? address.port : 0;
|
||||
if (this.port === 0) {
|
||||
this.startingPromise = null;
|
||||
reject(new Error('Failed to bind to any port'));
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
|
||||
);
|
||||
resolve(this.port);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
this.startingPromise = null;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
return this.startingPromise;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.server) return;
|
||||
|
||||
// Forcefully close all active connections
|
||||
for (const socket of this.activeConnections) {
|
||||
socket.destroy();
|
||||
}
|
||||
this.activeConnections.clear();
|
||||
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
this.startingPromise = null;
|
||||
this.log('Stopped');
|
||||
}
|
||||
|
||||
getPort(): number | null {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
private buildForwardHeaders(originalHeaders: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
||||
const headers: http.OutgoingHttpHeaders = {};
|
||||
|
||||
// RFC 7230 hop-by-hop headers that should not be forwarded
|
||||
const hopByHop = new Set([
|
||||
'host',
|
||||
'connection',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
for (const [key, value] of Object.entries(originalHeaders)) {
|
||||
if (!value) continue;
|
||||
const lower = key.toLowerCase();
|
||||
if (hopByHop.has(lower)) continue;
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
// Set correct host header for remote
|
||||
headers['Host'] = this.config.remoteHost;
|
||||
|
||||
// Inject Authorization header if not present but authToken is configured
|
||||
// This is a fallback - normally the client (CodexReasoningProxy) forwards the header
|
||||
if (!headers['authorization'] && !headers['Authorization'] && this.config.authToken) {
|
||||
headers['Authorization'] = `Bearer ${this.config.authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const method = req.method || 'GET';
|
||||
const requestPath = req.url || '/';
|
||||
|
||||
this.log(
|
||||
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
|
||||
);
|
||||
|
||||
try {
|
||||
await this.forwardRequest(req, res, requestPath);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log(`Error: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
// Sanitize error message: show details only in verbose mode (localhost-only anyway)
|
||||
const errorMessage = this.config.verbose ? err.message : 'Upstream request failed';
|
||||
res.end(JSON.stringify({ error: errorMessage }));
|
||||
}
|
||||
}
|
||||
|
||||
private forwardRequest(
|
||||
originalReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
requestPath: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = this.buildForwardHeaders(originalReq.headers);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: this.config.remoteHost,
|
||||
port: this.config.remotePort,
|
||||
path: requestPath,
|
||||
method: originalReq.method,
|
||||
timeout: this.config.timeoutMs,
|
||||
headers,
|
||||
// Allow self-signed certificates if configured
|
||||
rejectUnauthorized: !this.config.allowSelfSigned,
|
||||
};
|
||||
|
||||
const upstreamReq = https.request(options, (upstreamRes) => {
|
||||
// Forward status and headers
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
|
||||
// Pipe response body
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve());
|
||||
upstreamRes.on('error', reject);
|
||||
});
|
||||
|
||||
upstreamReq.on('timeout', () => {
|
||||
const timeoutError = new Error('Upstream request timeout');
|
||||
this.log(`Timeout: ${timeoutError.message}`);
|
||||
upstreamReq.destroy();
|
||||
reject(timeoutError);
|
||||
});
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
this.log(`Upstream error: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Handle client disconnect (premature close)
|
||||
originalReq.on('error', (err) => {
|
||||
this.log(`Client request error: ${err.message}`);
|
||||
upstreamReq.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
originalReq.on('close', () => {
|
||||
if (!originalReq.complete) {
|
||||
this.log('Client disconnected prematurely');
|
||||
upstreamReq.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe request body to upstream
|
||||
originalReq.pipe(upstreamReq);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import {
|
||||
getProviderAccounts,
|
||||
isAccountPaused,
|
||||
setAccountTier,
|
||||
type AccountInfo,
|
||||
type AccountTier,
|
||||
@@ -553,6 +554,18 @@ export async function fetchAccountQuota(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if account is paused (token moved to auth-paused/ directory)
|
||||
if (isAccountPaused(provider, accountId)) {
|
||||
const error = 'Account is paused';
|
||||
if (verbose) console.error(`[i] ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// Read auth data from auth file
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Remote Token Uploader
|
||||
*
|
||||
* Uploads OAuth tokens to remote CLIProxyAPI server after local authentication.
|
||||
* Enables multi-device access to the same OAuth accounts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getProxyTarget, buildProxyUrl } from './proxy-target-resolver';
|
||||
import { info, ok, fail, warn } from '../utils/ui';
|
||||
|
||||
/** Timeout for upload requests (ms) */
|
||||
const UPLOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
/** Response from POST /v0/management/auth-files */
|
||||
interface UploadResponse {
|
||||
status?: string;
|
||||
success?: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a token file to remote CLIProxyAPI server.
|
||||
* Uses multipart/form-data as required by CLIProxyAPI.
|
||||
*
|
||||
* @param tokenFilePath - Path to local token JSON file
|
||||
* @param verbose - Enable verbose logging
|
||||
* @returns true if upload succeeded
|
||||
*/
|
||||
export async function uploadTokenToRemote(
|
||||
tokenFilePath: string,
|
||||
verbose = false
|
||||
): Promise<boolean> {
|
||||
const target = getProxyTarget();
|
||||
|
||||
if (!target.isRemote) {
|
||||
if (verbose) {
|
||||
console.error('[upload] Remote mode not enabled, skipping upload');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read token file
|
||||
let tokenContent: string;
|
||||
try {
|
||||
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(fail(`Failed to read token file: ${(error as Error).message}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate JSON
|
||||
try {
|
||||
JSON.parse(tokenContent);
|
||||
} catch {
|
||||
console.error(fail('Invalid token file: not valid JSON'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = path.basename(tokenFilePath);
|
||||
const url = buildProxyUrl(target, '/v0/management/auth-files');
|
||||
|
||||
// Use Authorization: Bearer header for authentication
|
||||
const authKey = target.managementKey ?? target.authToken;
|
||||
|
||||
if (verbose) {
|
||||
console.error(`[upload] Uploading ${fileName} to ${target.host}`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// CLIProxyAPI requires multipart/form-data with "file" field
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([tokenContent], { type: 'application/json' });
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (authKey) {
|
||||
headers['Authorization'] = `Bearer ${authKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error(fail(`Upload failed: ${response.status} ${text}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as UploadResponse;
|
||||
|
||||
if (result.status === 'ok' || result.success || result.id) {
|
||||
console.log(ok(`Token uploaded to remote server: ${fileName}`));
|
||||
return true;
|
||||
} else {
|
||||
console.error(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error(fail('Upload timed out'));
|
||||
} else {
|
||||
console.error(fail(`Upload failed: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload all tokens from a provider directory to remote server.
|
||||
*
|
||||
* @param tokenDir - Directory containing token files
|
||||
* @param verbose - Enable verbose logging
|
||||
* @returns Number of successfully uploaded tokens
|
||||
*/
|
||||
export async function uploadAllTokensToRemote(tokenDir: string, verbose = false): Promise<number> {
|
||||
const target = getProxyTarget();
|
||||
|
||||
if (!target.isRemote) {
|
||||
if (verbose) {
|
||||
console.error('[upload] Remote mode not enabled, skipping upload');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
if (verbose) {
|
||||
console.error(`[upload] Token directory does not exist: ${tokenDir}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(tokenDir).filter((f) => f.endsWith('.json'));
|
||||
|
||||
if (files.length === 0) {
|
||||
if (verbose) {
|
||||
console.error('[upload] No token files found');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(info(`Uploading ${files.length} token(s) to remote server...`));
|
||||
|
||||
let uploaded = 0;
|
||||
for (const file of files) {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
const success = await uploadTokenToRemote(filePath, verbose);
|
||||
if (success) {
|
||||
uploaded++;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded > 0) {
|
||||
console.log(ok(`Uploaded ${uploaded}/${files.length} token(s) to ${target.host}`));
|
||||
} else {
|
||||
console.log(warn('No tokens were uploaded'));
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if remote upload is enabled and configured.
|
||||
*/
|
||||
export function isRemoteUploadEnabled(): boolean {
|
||||
const target = getProxyTarget();
|
||||
return target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ describe('npm CLI', () => {
|
||||
describe('Error handling', () => {
|
||||
it('handles empty arguments gracefully', function() {
|
||||
try {
|
||||
runCli('', { stdio: 'pipe' });
|
||||
runCli('', { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
// Should either succeed or fail gracefully with a helpful error
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
@@ -152,7 +152,7 @@ describe('npm CLI', () => {
|
||||
it('handles very long argument', function() {
|
||||
const longArg = 'a'.repeat(1000);
|
||||
try {
|
||||
runCli(`"${longArg}"`, { stdio: 'pipe' });
|
||||
runCli(`"${longArg}"`, { stdio: 'pipe', timeout: 3000 });
|
||||
} catch (e) {
|
||||
// Should handle gracefully, not crash
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
|
||||
@@ -88,4 +88,110 @@ describe('Codex Reasoning Proxy', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPathPrefix (remote mode)', () => {
|
||||
// Tests the path prefix stripping logic used in remote proxy mode
|
||||
// Remote CLIProxyAPI expects /v1/messages, but Claude sends /api/provider/codex/v1/messages
|
||||
|
||||
/**
|
||||
* Updated to match the version in codex-reasoning-proxy.ts with boundary check.
|
||||
* Only strips if prefix matches a complete path segment (not partial like /codex matching /codextra)
|
||||
*/
|
||||
function stripPathPrefix(path, prefix) {
|
||||
if (
|
||||
prefix &&
|
||||
path.startsWith(prefix) &&
|
||||
(path.length === prefix.length || path[prefix.length] === '/')
|
||||
) {
|
||||
let stripped = path.slice(prefix.length);
|
||||
// Normalize: collapse any leading slashes to single slash and ensure path starts with '/'
|
||||
stripped = stripped.replace(/^\/+/, '/') || '/';
|
||||
if (!stripped.startsWith('/')) {
|
||||
stripped = '/' + stripped;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
it('strips /api/provider/codex prefix for remote mode', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('returns root path when prefix equals full path', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/');
|
||||
});
|
||||
|
||||
it('leaves path unchanged when prefix is undefined', () => {
|
||||
const result = stripPathPrefix('/v1/messages', undefined);
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('leaves path unchanged when prefix does not match', () => {
|
||||
const result = stripPathPrefix('/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles empty prefix', () => {
|
||||
const result = stripPathPrefix('/v1/messages', '');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles various provider prefixes', () => {
|
||||
// Gemini
|
||||
assert.strictEqual(
|
||||
stripPathPrefix('/api/provider/gemini/v1/chat', '/api/provider/gemini'),
|
||||
'/v1/chat'
|
||||
);
|
||||
// Agy
|
||||
assert.strictEqual(
|
||||
stripPathPrefix('/api/provider/agy/v1/messages', '/api/provider/agy'),
|
||||
'/v1/messages'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves query strings after stripping', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex/v1/messages?stream=true', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages?stream=true');
|
||||
});
|
||||
|
||||
// Edge cases for path normalization
|
||||
it('collapses double slashes after stripping', () => {
|
||||
// e.g., '/api/provider/codex//v1/messages' → '/v1/messages'
|
||||
const result = stripPathPrefix('/api/provider/codex//v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1/messages');
|
||||
});
|
||||
|
||||
it('handles multiple leading slashes after stripping', () => {
|
||||
// e.g., '/api/provider/codex///v1' → '/v1'
|
||||
const result = stripPathPrefix('/api/provider/codex///v1', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1');
|
||||
});
|
||||
|
||||
it('adds leading slash if missing after strip', () => {
|
||||
const result = stripPathPrefix('/prefix/suffix', '/prefix');
|
||||
assert.strictEqual(result, '/suffix');
|
||||
});
|
||||
|
||||
// Boundary check tests - prevent partial segment matching
|
||||
it('does NOT strip partial path segment matches', () => {
|
||||
// /codex should NOT match /codextra
|
||||
const result = stripPathPrefix('/api/provider/codextra/v1/messages', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/api/provider/codextra/v1/messages');
|
||||
});
|
||||
|
||||
it('does NOT strip when prefix matches but next char is not slash', () => {
|
||||
// /api should NOT match /api-v2
|
||||
const result = stripPathPrefix('/api-v2/messages', '/api');
|
||||
assert.strictEqual(result, '/api-v2/messages');
|
||||
});
|
||||
|
||||
it('strips when prefix matches exactly with slash boundary', () => {
|
||||
// /api/provider/codex should match /api/provider/codex/v1
|
||||
const result = stripPathPrefix('/api/provider/codex/v1', '/api/provider/codex');
|
||||
assert.strictEqual(result, '/v1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* HTTPS Tunnel Proxy Tests
|
||||
*
|
||||
* Tests for HttpsTunnelProxy which tunnels HTTP requests to remote HTTPS CLIProxyAPI.
|
||||
* Required because Claude Code (undici) doesn't support HTTPS in ANTHROPIC_BASE_URL.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
// Import the class under test
|
||||
import { HttpsTunnelProxy, type HttpsTunnelConfig } from '../../../src/cliproxy/https-tunnel-proxy';
|
||||
|
||||
/**
|
||||
* Self-signed certificate for testing HTTPS servers.
|
||||
* Generated with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
|
||||
*/
|
||||
const TEST_CERT = {
|
||||
key: `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1Ji8aq3YnxiRC
|
||||
i5syvghdG+08f9Gc1C55UiNZc5zxY6Ij73pg72fWO614WH5MT3GeeDHdA4jF/xxZ
|
||||
tgLecJ14L5CyqpbcFYnayjRS5WjWfG40VOsVAf5OiJem6YL4Yu9EExO1MxhzIQmW
|
||||
fqijCSBVPiYkJ9CoT1EuUMPBudWkxs5NQHUYJu2Hq/mG89W+yMuT+Yp9YKau1snb
|
||||
x0I4aqf+OBJKrlWEZ+tgcTbyWW0bvQv8Ou9cFZjsXQF8jXBHJ97/LPW850jKt76J
|
||||
6PjIZeaTScZo9Py/fSIf8+4XYFHr3TVmUpoa1f1jmp+kE0H51+KDnqbcYCB6EwFm
|
||||
UFRaJ6ZtAgMBAAECggEAB5j3DMqi2qmIHR+3KKT+ZiP6X+PfKhFeyZ5/oQwk9Egr
|
||||
erpb4EjqNVACHIle8rBk9t0vqjIFwILMm5lIptpY9bt4+XqyImo81+eh04A6VL9E
|
||||
l/6fxXJ0n11B5Fw9g/wSRkFOkvZLpjh9Kx9befWzXMqN1aJd2/vyTwZQJNs4cgAF
|
||||
BUppw0PG1ujLXl48pNoGqMVLALWA1XwlexBxh6EgU9rsar9desqqhF1pZIAimB4x
|
||||
pvQSPqENFjCOMY93RRvZHITE37Y61BiofdHHqIDLAqYZLHlq+rUupYe9auUHMpaD
|
||||
KFV99x9gfVS9l0aqzxXw+nqar9o1+h5lWO2hCw3HgQKBgQD1n19aJ3z1TC1bRLGV
|
||||
H1UTgx/TAfFsXB7S3RO+Tkfhn+g169ByOWRELIGptOSBYauo/N29AYNHjNJPZDYM
|
||||
sraJfyLStrcCTzXcum1Xfx6rjyq5LU5D93F6ZXGBlVrLmk/+YEep65g0X82VzbPN
|
||||
9aIKW2kBLKuH2O9MwaqWko+ZEQKBgQC8zXlgsq2fkKeyRJfIAN8+Wx2Kd1n5KPbl
|
||||
nvbj9k59oYBz1yggj9v6vjfo9MrgZxp5LmR5UgGsSFpqXpkA7SDYCvKZUwQj/eIx
|
||||
LhV6NG+SnbFKtinIuh9GHiEKGNxJsRL7ZljVjW6f6C4f4MeyEW2IpE5AMAaCfpUS
|
||||
JxI3afJXnQKBgBcKsGNAuRQ55Tdeploa6lw+PMoKsJ89tRaK7sM3jL65xYrpaFCO
|
||||
2b0bf75v3c/VXckoj5Sfg7U+nKwd9oQSb9VOO/IQefKZg7AFPSSsJDBr6dIdUe5G
|
||||
VDrrMU66uB3JiB+Q4KgsFcc0BZE8DtYPaPgXwy39Bspjq29D68DcVuRBAoGAZbA9
|
||||
qalS/lhJGij7nwtpMgqdNJDn8tzvbelajJmC2QN9Tecag783+istrdj61DZz+cTU
|
||||
9MsIf6RQnm3o9qjBQdtTouUlm8UIaPirNLC9TziD3vuSMbydT4S2wtt0+nPXB3Su
|
||||
cAbHCHVjMmQ86lmcpzXnt4amWu6Wl7pXg2Ua07kCgYEA8XMfXql48oUVIr+cN5AN
|
||||
nnql5ojMi2Vp9SeTDM/LKCJ9HORCKi4DyHqm06OjDFi2Al57DVsLHpxENWVSgUmp
|
||||
OpcE1P2kqmDqQguFg9GUPX38zspijdVBd1rtpPfHsAu+ZvRWT8ozFLKZ3Xjg1fIx
|
||||
TL6BeOBii9TlZ66SlZT5HRM=
|
||||
-----END PRIVATE KEY-----`,
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIUfoHoOgjjiqPNOxpbp7jHBFSfTeEwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDExNDE1MzAyMFoXDTI3MDEx
|
||||
NDE1MzAyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAtSYvGqt2J8YkQoubMr4IXRvtPH/RnNQueVIjWXOc8WOi
|
||||
I+96YO9n1juteFh+TE9xnngx3QOIxf8cWbYC3nCdeC+QsqqW3BWJ2so0UuVo1nxu
|
||||
NFTrFQH+ToiXpumC+GLvRBMTtTMYcyEJln6oowkgVT4mJCfQqE9RLlDDwbnVpMbO
|
||||
TUB1GCbth6v5hvPVvsjLk/mKfWCmrtbJ28dCOGqn/jgSSq5VhGfrYHE28lltG70L
|
||||
/DrvXBWY7F0BfI1wRyfe/yz1vOdIyre+iej4yGXmk0nGaPT8v30iH/PuF2BR6901
|
||||
ZlKaGtX9Y5qfpBNB+dfig56m3GAgehMBZlBUWiembQIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQUHvZklBlcvTOIuN/xSO7BP7rgSqswHwYDVR0jBBgwFoAUHvZklBlcvTOIuN/x
|
||||
SO7BP7rgSqswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAWRj4
|
||||
bB1eObtMOal4VPmL5iX07XzL4hp6Dwu2LSrw9KMArqTTeyJEGNSsykuHPZwPIflD
|
||||
JkzfrFfDv8q7YDpSLy9vJN2E+SPn/Oq2BehUmD+uURghaoSsXeyY9Kv6vGZri95l
|
||||
rgg+6wLJDVrnw5tKxEHx5hUyVR3Ms4LwU/hwAcCGCxx5exhvLpfjjxGBR814kCEc
|
||||
IKGISNjqDo1Pz1Xm8QBLzG4CtlzE/QEbkJKImmwskv6vvoRbWg+B529WzFFCReYK
|
||||
/sULgpvG29Uc3MwZK242dKyTUFdI6tQuZ8xierXwP0kIFlP2phtkgE9kjQJqrtRZ
|
||||
fago/IeVI/sKlApDxA==
|
||||
-----END CERTIFICATE-----`,
|
||||
};
|
||||
|
||||
describe('HttpsTunnelProxy', () => {
|
||||
let tunnel: HttpsTunnelProxy | null = null;
|
||||
let mockServer: https.Server | null = null;
|
||||
let mockServerPort: number = 0;
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up tunnel
|
||||
if (tunnel) {
|
||||
tunnel.stop();
|
||||
tunnel = null;
|
||||
}
|
||||
// Clean up mock server
|
||||
if (mockServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.close(() => resolve());
|
||||
});
|
||||
mockServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should apply default values for optional config', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
// The proxy should be created without error
|
||||
expect(tunnel).toBeDefined();
|
||||
expect(tunnel.getPort()).toBeNull(); // Not started yet
|
||||
});
|
||||
|
||||
it('should accept custom config values', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'custom.host.com',
|
||||
remotePort: 8443,
|
||||
authToken: 'test-token',
|
||||
timeoutMs: 30000,
|
||||
verbose: true,
|
||||
allowSelfSigned: true,
|
||||
});
|
||||
|
||||
expect(tunnel).toBeDefined();
|
||||
});
|
||||
|
||||
// Hostname validation tests
|
||||
it('should throw error for empty hostname', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: '' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with protocol prefix', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'https://example.com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with spaces', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'example .com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should throw error for hostname with invalid characters', () => {
|
||||
expect(() => {
|
||||
new HttpsTunnelProxy({ remoteHost: 'example@com' });
|
||||
}).toThrow('Invalid remoteHost format');
|
||||
});
|
||||
|
||||
it('should accept valid hostnames', () => {
|
||||
// Standard domain
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'example.com' })).not.toThrow();
|
||||
// Subdomain
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'api.example.com' })).not.toThrow();
|
||||
// With dashes
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'my-api.example-site.com' })).not.toThrow();
|
||||
// IP-like
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: '192.168.1.1' })).not.toThrow();
|
||||
// Localhost
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'localhost' })).not.toThrow();
|
||||
// Single char hostname
|
||||
expect(() => new HttpsTunnelProxy({ remoteHost: 'a' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start()', () => {
|
||||
it('should start server and return valid port', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(port).toBeLessThan(65536);
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
|
||||
it('should return same port on subsequent start() calls', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port1 = await tunnel.start();
|
||||
const port2 = await tunnel.start();
|
||||
|
||||
expect(port1).toBe(port2);
|
||||
});
|
||||
|
||||
it('should bind to localhost only (127.0.0.1)', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Try to connect - should work on localhost
|
||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const req = http.get(`http://127.0.0.1:${port}/test`, resolve);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(1000);
|
||||
}).catch((err) => err);
|
||||
|
||||
// We expect an error because there's no upstream, but connection should be accepted
|
||||
// If binding failed, we'd get ECONNREFUSED before any response
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
it('should clear port after stop', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
await tunnel.start();
|
||||
expect(tunnel.getPort()).not.toBeNull();
|
||||
|
||||
tunnel.stop();
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should be idempotent (safe to call multiple times)', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
// Stop without start - should not throw
|
||||
tunnel.stop();
|
||||
tunnel.stop();
|
||||
tunnel.stop();
|
||||
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow restart after stop', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
const port1 = await tunnel.start();
|
||||
tunnel.stop();
|
||||
|
||||
const port2 = await tunnel.start();
|
||||
|
||||
expect(port2).toBeGreaterThan(0);
|
||||
// Ports may or may not be the same depending on OS port reuse
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPort()', () => {
|
||||
it('should return null before start', () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
expect(tunnel.getPort()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid port after start', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
});
|
||||
|
||||
await tunnel.start();
|
||||
|
||||
const port = tunnel.getPort();
|
||||
expect(port).not.toBeNull();
|
||||
expect(port).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildForwardHeaders (Authorization injection)', () => {
|
||||
// We test this indirectly through the proxy behavior
|
||||
// The buildForwardHeaders method is private, so we verify via integration
|
||||
|
||||
it('should forward existing Authorization header', async () => {
|
||||
// This test requires a mock HTTPS server
|
||||
// For now, we document the expected behavior
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'example.com',
|
||||
authToken: 'fallback-token',
|
||||
};
|
||||
|
||||
tunnel = new HttpsTunnelProxy(config);
|
||||
await tunnel.start();
|
||||
|
||||
// The tunnel should:
|
||||
// 1. Forward 'Authorization' header if present in request
|
||||
// 2. Inject 'Authorization: Bearer fallback-token' if not present
|
||||
expect(tunnel.getPort()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hop-by-hop headers filtering', () => {
|
||||
// RFC 7230 hop-by-hop headers should be filtered
|
||||
const hopByHopHeaders = [
|
||||
'host',
|
||||
'connection',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'upgrade',
|
||||
];
|
||||
|
||||
it('should define all RFC 7230 hop-by-hop headers for filtering', () => {
|
||||
// Document expected filtered headers
|
||||
expect(hopByHopHeaders).toContain('connection');
|
||||
expect(hopByHopHeaders).toContain('transfer-encoding');
|
||||
expect(hopByHopHeaders).toContain('keep-alive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection tracking', () => {
|
||||
it('should track active connections for cleanup', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Create a connection
|
||||
const net = await import('net');
|
||||
const socket = new net.Socket();
|
||||
|
||||
// Use a promise that resolves when connection is established
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.on('error', reject);
|
||||
socket.connect(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
// Give the server time to register the connection
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Stop should forcefully close all connections and the server
|
||||
tunnel.stop();
|
||||
|
||||
// Verify stop() was called successfully (server is null after stop)
|
||||
// The key behavior is that stop() destroys server-side sockets
|
||||
// and clears activeConnections - we verify by checking getPort() returns null
|
||||
expect(tunnel.getPort()).toBe(null);
|
||||
|
||||
// Clean up client socket
|
||||
socket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it(
|
||||
'should handle upstream timeout',
|
||||
async () => {
|
||||
// Create an HTTPS server that accepts connections but delays response
|
||||
// beyond the tunnel's timeout. This tests the socket-level timeout.
|
||||
const slowServer = https.createServer(
|
||||
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
|
||||
(req, res) => {
|
||||
// Delay response beyond tunnel timeout (500ms)
|
||||
// The tunnel should timeout before this completes
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end('too late');
|
||||
}, 2000);
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
slowServer.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const slowPort = (slowServer.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: slowPort,
|
||||
timeoutMs: 500, // Short timeout - server responds after 2000ms
|
||||
allowSelfSigned: true,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Make request - should timeout because server delays 2s but tunnel times out at 500ms
|
||||
const response = await new Promise<http.IncomingMessage | Error>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
resolve
|
||||
);
|
||||
req.on('error', (err) => resolve(err)); // Resolve with error instead of reject
|
||||
req.setTimeout(3000); // Client timeout longer than tunnel timeout
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
|
||||
// Either 502 response or connection error is acceptable
|
||||
// The tunnel should have timed out and returned 502 or closed the connection
|
||||
expect(response).toBeDefined();
|
||||
if (response instanceof http.IncomingMessage) {
|
||||
expect(response.statusCode).toBe(502);
|
||||
}
|
||||
} finally {
|
||||
slowServer.close();
|
||||
}
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
it('should handle client disconnect (premature close)', async () => {
|
||||
// Create a slow HTTPS server that holds connection
|
||||
// HttpsTunnelProxy uses https.request(), so we need an HTTPS server
|
||||
const slowServer = https.createServer(
|
||||
{ key: TEST_CERT.key, cert: TEST_CERT.cert },
|
||||
(req, res) => {
|
||||
// Wait before responding
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
}, 2000);
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
slowServer.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const slowPort = (slowServer.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: slowPort,
|
||||
timeoutMs: 10000,
|
||||
allowSelfSigned: true,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Make request and immediately abort
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
req.write('{}');
|
||||
req.end();
|
||||
|
||||
// Abort after a short delay to trigger premature close
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
req.destroy();
|
||||
|
||||
// Give time for error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Tunnel should still be operational
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
} finally {
|
||||
slowServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle client request error', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'example.com',
|
||||
remotePort: 443,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Create socket and send malformed request
|
||||
const net = await import('net');
|
||||
const socket = new net.Socket();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.connect(port, '127.0.0.1', () => {
|
||||
// Send partial HTTP request then destroy
|
||||
socket.write('POST /test HTTP/1.1\r\n');
|
||||
socket.write('Content-Length: 100\r\n\r\n'); // Claim 100 bytes
|
||||
socket.write('partial'); // Only send partial body
|
||||
socket.destroy(); // Trigger error
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', () => resolve()); // Ignore socket errors
|
||||
});
|
||||
|
||||
// Give time for error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Tunnel should still be operational
|
||||
expect(tunnel.getPort()).toBe(port);
|
||||
});
|
||||
|
||||
it('should handle upstream connection errors gracefully', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'nonexistent.invalid.host',
|
||||
remotePort: 12345,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
// Make request to tunnel - should get 502 error
|
||||
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
resolve
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000);
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
});
|
||||
|
||||
it('should return JSON error response', async () => {
|
||||
tunnel = new HttpsTunnelProxy({
|
||||
remoteHost: 'nonexistent.invalid.host',
|
||||
remotePort: 12345,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
const port = await tunnel.start();
|
||||
|
||||
const body = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/v1/messages',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(5000);
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(body);
|
||||
expect(parsed).toHaveProperty('error');
|
||||
expect(typeof parsed.error).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config interface', () => {
|
||||
it('should export HttpsTunnelConfig type', () => {
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'test.com',
|
||||
remotePort: 443,
|
||||
authToken: 'token',
|
||||
timeoutMs: 60000,
|
||||
verbose: false,
|
||||
allowSelfSigned: false,
|
||||
};
|
||||
|
||||
expect(config.remoteHost).toBe('test.com');
|
||||
expect(config.remotePort).toBe(443);
|
||||
});
|
||||
|
||||
it('should allow minimal config with only remoteHost', () => {
|
||||
const config: HttpsTunnelConfig = {
|
||||
remoteHost: 'minimal.test.com',
|
||||
};
|
||||
|
||||
expect(config.remoteHost).toBe('minimal.test.com');
|
||||
expect(config.remotePort).toBeUndefined();
|
||||
expect(config.authToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpsTunnelProxy stripPathPrefix integration with CodexReasoningProxy', () => {
|
||||
// Document the integration pattern between HttpsTunnelProxy and CodexReasoningProxy
|
||||
// In remote mode, the path flow is:
|
||||
// Claude → CodexReasoningProxy → HttpsTunnelProxy → Remote CLIProxyAPI
|
||||
//
|
||||
// CodexReasoningProxy strips /api/provider/codex prefix before forwarding
|
||||
// HttpsTunnelProxy then tunnels HTTP→HTTPS to remote server
|
||||
|
||||
it('should document path transformation for remote mode', () => {
|
||||
// Remote CLIProxyAPI expects: /v1/messages
|
||||
// Local CLIProxy expects: /api/provider/codex/v1/messages
|
||||
//
|
||||
// CodexReasoningProxy.stripPathPrefix handles this transformation:
|
||||
// Input: /api/provider/codex/v1/messages
|
||||
// Output: /v1/messages
|
||||
const inputPath = '/api/provider/codex/v1/messages';
|
||||
const prefix = '/api/provider/codex';
|
||||
const expectedOutput = '/v1/messages';
|
||||
|
||||
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
|
||||
|
||||
expect(result).toBe(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle root path after prefix strip', () => {
|
||||
const inputPath = '/api/provider/codex';
|
||||
const prefix = '/api/provider/codex';
|
||||
|
||||
const result = inputPath.startsWith(prefix) ? inputPath.slice(prefix.length) || '/' : inputPath;
|
||||
|
||||
expect(result).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Remote Token Uploader Tests
|
||||
*
|
||||
* Tests for uploadTokenToRemote and related functions.
|
||||
* Uses a local HTTP server to mock the remote CLIProxyAPI.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
// We need to mock getProxyTarget before importing the module
|
||||
// Since the module reads config at import time, we'll test the pure functions
|
||||
|
||||
describe('remote-token-uploader', () => {
|
||||
let mockServer: http.Server | null = null;
|
||||
let mockServerPort: number = 0;
|
||||
let tempDir: string;
|
||||
let tempTokenFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory and token file
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
tempTokenFile = path.join(tempDir, 'test-token.json');
|
||||
fs.writeFileSync(
|
||||
tempTokenFile,
|
||||
JSON.stringify({
|
||||
type: 'gemini',
|
||||
email: 'test@example.com',
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up mock server
|
||||
if (mockServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.close(() => resolve());
|
||||
});
|
||||
mockServer = null;
|
||||
}
|
||||
|
||||
// Clean up temp files
|
||||
try {
|
||||
fs.unlinkSync(tempTokenFile);
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('uploadTokenToRemote', () => {
|
||||
it('should upload token file successfully', async () => {
|
||||
// Create mock server that accepts uploads
|
||||
let receivedRequest: {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
} | null = null;
|
||||
|
||||
mockServer = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
receivedRequest = {
|
||||
method: req.method || '',
|
||||
path: req.url || '',
|
||||
headers: req.headers,
|
||||
body,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', id: 'uploaded-123' }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
// Mock getProxyTarget to return our test server
|
||||
const mockProxyTarget = {
|
||||
isRemote: true,
|
||||
host: `127.0.0.1:${mockServerPort}`,
|
||||
protocol: 'http' as const,
|
||||
authToken: 'test-auth-token',
|
||||
managementKey: 'test-mgmt-key',
|
||||
};
|
||||
|
||||
// Dynamically import and test
|
||||
// We need to test the fetch logic directly since module caches getProxyTarget
|
||||
const url = `http://${mockProxyTarget.host}/v0/management/auth-files`;
|
||||
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
|
||||
const fileName = path.basename(tempTokenFile);
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([tokenContent], { type: 'application/json' });
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mockProxyTarget.managementKey}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result).toHaveProperty('status', 'ok');
|
||||
expect(result).toHaveProperty('id', 'uploaded-123');
|
||||
|
||||
// Verify request was received correctly
|
||||
expect(receivedRequest).not.toBeNull();
|
||||
expect(receivedRequest!.method).toBe('POST');
|
||||
expect(receivedRequest!.path).toBe('/v0/management/auth-files');
|
||||
expect(receivedRequest!.headers['authorization']).toBe('Bearer test-mgmt-key');
|
||||
});
|
||||
|
||||
it('should handle upload failure gracefully', async () => {
|
||||
// Create mock server that returns error
|
||||
mockServer = http.createServer((req, res) => {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const url = `http://127.0.0.1:${mockServerPort}/v0/management/auth-files`;
|
||||
const tokenContent = fs.readFileSync(tempTokenFile, 'utf-8');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([tokenContent], { type: 'application/json' }), 'test.json');
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(false);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle connection timeout', async () => {
|
||||
// Create server that never responds
|
||||
mockServer = http.createServer(() => {
|
||||
// Never respond
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const url = `http://127.0.0.1:${mockServerPort}/v0/management/auth-files`;
|
||||
const controller = new AbortController();
|
||||
|
||||
// Set short timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (error) {
|
||||
expect((error as Error).name).toBe('AbortError');
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle connection refused', async () => {
|
||||
// Try to connect to a port with nothing listening
|
||||
const url = 'http://127.0.0.1:59999/v0/management/auth-files';
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
// Should not reach here in most cases
|
||||
} catch (error) {
|
||||
// Connection refused is expected
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemoteUploadEnabled', () => {
|
||||
it('should return false when not in remote mode', () => {
|
||||
// Test the logic directly
|
||||
const target = { isRemote: false, authToken: 'token' };
|
||||
const result = target.isRemote && Boolean(target.authToken);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when remote but no auth', () => {
|
||||
const target = { isRemote: true, authToken: undefined, managementKey: undefined };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when remote with authToken', () => {
|
||||
const target = { isRemote: true, authToken: 'token', managementKey: undefined };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when remote with managementKey', () => {
|
||||
const target = { isRemote: true, authToken: undefined, managementKey: 'mgmt-key' };
|
||||
const result = target.isRemote && Boolean(target.managementKey ?? target.authToken);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer managementKey over authToken', () => {
|
||||
const target = { isRemote: true, authToken: 'auth', managementKey: 'mgmt' };
|
||||
const key = target.managementKey ?? target.authToken;
|
||||
expect(key).toBe('mgmt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token file validation', () => {
|
||||
it('should reject invalid JSON', async () => {
|
||||
// Create invalid token file
|
||||
const invalidTokenFile = path.join(tempDir, 'invalid.json');
|
||||
fs.writeFileSync(invalidTokenFile, 'not valid json {{{');
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(invalidTokenFile, 'utf-8');
|
||||
JSON.parse(content);
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('JSON');
|
||||
} finally {
|
||||
fs.unlinkSync(invalidTokenFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing file', () => {
|
||||
try {
|
||||
fs.readFileSync('/nonexistent/path/token.json', 'utf-8');
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('ENOENT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multipart/form-data construction', () => {
|
||||
it('should construct valid FormData with file field', () => {
|
||||
const tokenContent = JSON.stringify({ type: 'test', token: 'abc' });
|
||||
const fileName = 'oauth-token.json';
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([tokenContent], { type: 'application/json' });
|
||||
formData.append('file', blob, fileName);
|
||||
|
||||
// FormData should have the file
|
||||
expect(formData.has('file')).toBe(true);
|
||||
|
||||
const file = formData.get('file') as File;
|
||||
expect(file).toBeDefined();
|
||||
expect(file.name).toBe(fileName);
|
||||
expect(file.type).toContain('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorization header', () => {
|
||||
it('should use Bearer token format', async () => {
|
||||
let capturedAuth: string | null = null;
|
||||
|
||||
mockServer = http.createServer((req, res) => {
|
||||
capturedAuth = req.headers['authorization'] as string;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const authToken = 'my-secret-token-123';
|
||||
await fetch(`http://127.0.0.1:${mockServerPort}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
expect(capturedAuth).toBe(`Bearer ${authToken}`);
|
||||
});
|
||||
|
||||
it('should not send Authorization when no token', async () => {
|
||||
let hasAuth = false;
|
||||
|
||||
mockServer = http.createServer((req, res) => {
|
||||
hasAuth = 'authorization' in req.headers;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
await fetch(`http://127.0.0.1:${mockServerPort}/test`, {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
expect(hasAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('response parsing', () => {
|
||||
it('should accept status: ok response', async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
const isSuccess = result.status === 'ok' || result.success || result.id;
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept success: true response', async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
const isSuccess = result.status === 'ok' || result.success || result.id;
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept id in response', async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 'file-abc123' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
|
||||
const result = (await response.json()) as { status?: string; success?: boolean; id?: string };
|
||||
|
||||
// result.id is truthy when present
|
||||
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
|
||||
expect(isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect error response', async () => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid token format' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
mockServer!.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
mockServerPort = (mockServer.address() as AddressInfo).port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${mockServerPort}/test`, { method: 'POST' });
|
||||
const result = (await response.json()) as {
|
||||
status?: string;
|
||||
success?: boolean;
|
||||
id?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Error response: none of the success indicators are present
|
||||
const isSuccess = result.status === 'ok' || result.success === true || Boolean(result.id);
|
||||
expect(isSuccess).toBe(false);
|
||||
expect(result.error).toBe('Invalid token format');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getMinClaudeQuota,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||
import { GripVertical, Loader2, Clock, Pause } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -202,9 +202,16 @@ export function AccountCard({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
quota.error === 'Account is paused' ? (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<Pause className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FolderCode,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
@@ -167,6 +169,54 @@ export function AccountItem({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Project ID for Antigravity accounts - read-only */}
|
||||
{account.provider === 'agy' && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{account.projectId ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono max-w-[180px] truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
title={account.projectId}
|
||||
>
|
||||
{account.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>
|
||||
This may cause errors. Remove the account and re-add it to fetch the
|
||||
project ID.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { ProviderStats } from '../types';
|
||||
import { getSuccessRate, cleanEmail } from '../utils';
|
||||
import { InlineStatsBadge } from './inline-stats-badge';
|
||||
@@ -102,16 +103,35 @@ export function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account color dots */}
|
||||
<div className="flex gap-1 mt-3">
|
||||
{stats.accounts.slice(0, 5).map((acc) => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
))}
|
||||
{/* Account color dots with warning for agy accounts missing projectId */}
|
||||
<div className="flex gap-1 mt-3 items-center">
|
||||
{stats.accounts.slice(0, 5).map((acc) => {
|
||||
const isMissingProjectId = stats.provider === 'agy' && !acc.projectId;
|
||||
return (
|
||||
<div key={acc.id} className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="absolute -top-1 -right-1 w-2.5 h-2.5 text-amber-500"
|
||||
aria-label="Missing Project ID"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Missing Project ID - re-add account to fix
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.accounts.length > 5 && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
+{stats.accounts.length - 5}
|
||||
|
||||
@@ -99,6 +99,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
failureCount: failure,
|
||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||
projectId: account.projectId,
|
||||
};
|
||||
accountsList.push(row);
|
||||
providerData.accounts.push(row);
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface AccountRow {
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
color: string;
|
||||
/** GCP Project ID (Antigravity only) - read-only */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface OAuthAccount {
|
||||
pausedAt?: string;
|
||||
/** Account tier: free or paid (Pro/Ultra combined) */
|
||||
tier?: 'free' | 'paid' | 'unknown';
|
||||
/** GCP Project ID (Antigravity only) - read-only */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
|
||||
Reference in New Issue
Block a user