From 94bf1fbfe9b970eb7ce86881e958ddc4880e0d79 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 13:12:40 +0900 Subject: [PATCH 01/21] feat(proxy): support profile-scoped local proxy ports --- src/commands/proxy-command.ts | 61 +++++++++----- src/config/unified-config-loader.ts | 5 ++ src/config/unified-config-types.ts | 8 ++ src/proxy/index.ts | 1 + src/proxy/proxy-daemon-paths.ts | 18 +++- src/proxy/proxy-daemon-state.ts | 50 ++++++++--- src/proxy/proxy-daemon.ts | 125 ++++++++++++++++++++-------- src/proxy/proxy-port-resolver.ts | 11 +++ 8 files changed, 205 insertions(+), 74 deletions(-) create mode 100644 src/proxy/proxy-port-resolver.ts diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 51c406f9..336472f0 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -5,6 +5,7 @@ import { fail, info, ok } from '../utils/ui'; import { buildOpenAICompatProxyEnv, getOpenAICompatProxyStatus, + listOpenAICompatProxyStatuses, resolveOpenAICompatProfileConfig, startOpenAICompatProxy, stopOpenAICompatProxy, @@ -30,9 +31,9 @@ function showHelp(): number { console.log( ' start Start the local proxy for an OpenAI-compatible settings profile' ); - console.log(' stop Stop the running proxy'); - console.log(' status Show daemon status and active profile'); - console.log(' activate Print shell exports for the running proxy'); + console.log(' stop [profile] Stop the running proxy (or all proxies when omitted)'); + console.log(' status [profile] Show daemon status'); + console.log(' activate [profile] Print shell exports for the running proxy'); console.log(''); console.log('Options:'); console.log(' --port Override the local proxy port (default: 3456)'); @@ -100,35 +101,50 @@ async function handleStart(args: string[]): Promise { return 0; } -async function handleStatus(): Promise { - const status = await getOpenAICompatProxyStatus(); - if (!status.running) { +async function handleStatus(args: string[] = []): Promise { + const profileName = args.find((arg) => !arg.startsWith('-')); + const running = profileName + ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) + : (await listOpenAICompatProxyStatuses()).filter((status) => status.running); + if (running.length === 0) { console.log(info('Proxy is not running')); return 0; } - console.log(ok(`Proxy running on port ${status.port}`)); - if (status.host) { - console.log(` Host: ${status.host}`); - console.log(` Local URL: http://${status.host}:${status.port}`); - } - console.log(` Profile: ${status.profileName}`); - console.log(` Base URL: ${status.baseUrl}`); - if (status.model) { - console.log(` Model: ${status.model}`); - } - if (status.pid) { - console.log(` PID: ${status.pid}`); + for (const status of running) { + console.log(ok(`Proxy running on port ${status.port}`)); + if (status.host) { + console.log(` Host: ${status.host}`); + console.log(` Local URL: http://${status.host}:${status.port}`); + } + console.log(` Profile: ${status.profileName}`); + console.log(` Base URL: ${status.baseUrl}`); + if (status.model) { + console.log(` Model: ${status.model}`); + } + if (status.pid) { + console.log(` PID: ${status.pid}`); + } } return 0; } async function handleActivate(args: string[]): Promise { - const status = await getOpenAICompatProxyStatus(); + const profileName = args.find((arg) => !arg.startsWith('-')); + const status = await getOpenAICompatProxyStatus(profileName); if (!status.running || !status.profileName || !status.port || !status.authToken) { console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); return 1; } + if (!profileName) { + const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); + if (running.length > 1) { + console.error( + fail('Multiple proxies are running. Specify a profile: ccs proxy activate ') + ); + return 1; + } + } const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell')); let profile; @@ -163,16 +179,17 @@ export async function handleProxyCommand(args: string[]): Promise { case 'start': return handleStart(args.slice(1)); case 'stop': { - const result = await stopOpenAICompatProxy(); + const profileName = args[1] && !args[1]?.startsWith('-') ? args[1] : undefined; + const result = await stopOpenAICompatProxy(profileName); if (!result.success) { console.error(fail(result.error || 'Failed to stop proxy')); return 1; } - console.log(ok('Proxy stopped')); + console.log(ok(profileName ? `Proxy stopped for profile ${profileName}` : 'Proxy stopped')); return 0; } case 'status': - return handleStatus(); + return handleStatus(args.slice(1)); case 'activate': return handleActivate(args.slice(1)); default: diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 50835435..a5a53672 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -19,6 +19,7 @@ import { DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_CLIPROXY_SAFETY_CONFIG, + DEFAULT_OPENAI_COMPAT_PROXY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, @@ -414,6 +415,10 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { }, }, proxy: { + port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, + profile_ports: partial.proxy?.profile_ports ?? { + ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports, + }, routing: { default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b8b67f4..41887804 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -508,6 +508,10 @@ export interface OpenAICompatProxyRoutingConfig { } export interface OpenAICompatProxyConfig { + /** Default local port for OpenAI-compatible proxy instances */ + port?: number; + /** Optional profile-scoped local port overrides */ + profile_ports?: Record; routing?: OpenAICompatProxyRoutingConfig; } @@ -986,6 +990,8 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { }; export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { + port: 3456, + profile_ports: {}, routing: { longContextThreshold: 60_000, }, @@ -1016,6 +1022,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, proxy: { + port: DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, + profile_ports: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports }, routing: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing, }, diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 24a0a516..2f389857 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -2,6 +2,7 @@ export * from './profile-router'; export * from './proxy-daemon'; export * from './proxy-daemon-paths'; export * from './proxy-env'; +export * from './proxy-port-resolver'; export * from './upstream-url'; export * from './transformers/request-transformer'; export * from './transformers/sse-stream-transformer'; diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 92b6b95b..011a9f59 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -8,10 +8,20 @@ export function getOpenAICompatProxyDir(): string { return path.join(getCcsDir(), 'proxy'); } -export function getOpenAICompatProxyPidPath(): string { - return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +export function getOpenAICompatProxyProfileKey(profileName: string): string { + return encodeURIComponent(profileName.trim()); } -export function getOpenAICompatProxySessionPath(): string { - return path.join(getOpenAICompatProxyDir(), 'session.json'); +export function getOpenAICompatProxyPidPath(profileName: string): string { + return path.join( + getOpenAICompatProxyDir(), + `${getOpenAICompatProxyProfileKey(profileName)}.daemon.pid` + ); +} + +export function getOpenAICompatProxySessionPath(profileName: string): string { + return path.join( + getOpenAICompatProxyDir(), + `${getOpenAICompatProxyProfileKey(profileName)}.session.json` + ); } diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 525c5a7e..82d11130 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -21,9 +21,9 @@ function ensureProxyDir(): void { fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); } -export function getOpenAICompatProxyPid(): number | null { +export function getOpenAICompatProxyPid(profileName: string): number | null { try { - const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim(); + const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim(); const pid = Number.parseInt(raw, 10); return Number.isInteger(pid) ? pid : null; } catch { @@ -31,23 +31,23 @@ export function getOpenAICompatProxyPid(): number | null { } } -export function writeOpenAICompatProxyPid(pid: number): void { +export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); - fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8'); + fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); } -export function removeOpenAICompatProxyPid(): void { +export function removeOpenAICompatProxyPid(profileName: string): void { try { - fs.unlinkSync(getOpenAICompatProxyPidPath()); + fs.unlinkSync(getOpenAICompatProxyPidPath(profileName)); } catch { // Best-effort cleanup. } } -export function readOpenAICompatProxySession(): OpenAICompatProxySession | null { +export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { try { return JSON.parse( - fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8') + fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8') ) as OpenAICompatProxySession; } catch { return null; @@ -57,20 +57,48 @@ export function readOpenAICompatProxySession(): OpenAICompatProxySession | null export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); fs.writeFileSync( - getOpenAICompatProxySessionPath(), + getOpenAICompatProxySessionPath(session.profileName), JSON.stringify(session, null, 2) + '\n', 'utf8' ); } -export function removeOpenAICompatProxySession(): void { +export function removeOpenAICompatProxySession(profileName: string): void { try { - fs.unlinkSync(getOpenAICompatProxySessionPath()); + fs.unlinkSync(getOpenAICompatProxySessionPath(profileName)); } catch { // Best-effort cleanup. } } +export function listOpenAICompatProxyProfileNames(): string[] { + try { + const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); + const profileKeys = new Set(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.session.json')) { + continue; + } + profileKeys.add(entry.name.slice(0, -'.session.json'.length)); + } + return [...profileKeys].map((profileKey) => decodeURIComponent(profileKey)); + } catch { + return []; + } +} + +export function cleanupLegacyOpenAICompatProxyState(): void { + ensureProxyDir(); + const legacyNames = ['daemon.pid', 'session.json']; + for (const legacyName of legacyNames) { + try { + fs.unlinkSync(path.join(getOpenAICompatProxyDir(), legacyName)); + } catch { + // Best-effort cleanup. + } + } +} + export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 8a28382e..b2cf668c 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -13,7 +13,9 @@ import { } from './proxy-daemon-paths'; import { getOpenAICompatProxyPid, + listOpenAICompatProxyProfileNames, readOpenAICompatProxySession, + cleanupLegacyOpenAICompatProxyState, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, @@ -21,6 +23,7 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; +import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -101,6 +104,13 @@ async function findOpenAICompatProxyPort(): Promise { return 0; } +async function findOpenAICompatProxyPortNear(preferredPort: number): Promise { + if (!(await isPortOccupied(preferredPort))) { + return preferredPort; + } + return findOpenAICompatProxyPort(); +} + async function resolveDaemonEntrypoint(): Promise { for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { try { @@ -144,21 +154,52 @@ export async function isOpenAICompatProxyRunning(port: number): Promise }); } -export async function getOpenAICompatProxyStatus(): Promise { - const session = readOpenAICompatProxySession(); +async function getOpenAICompatProxyStatusForProfile( + profileName: string +): Promise { + const session = readOpenAICompatProxySession(profileName); const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; const running = await isOpenAICompatProxyRunning(port); return { running, - pid: running ? getOpenAICompatProxyPid() || undefined : undefined, + pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined, ...session, }; } -async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> { - const pid = getOpenAICompatProxyPid(); +export async function listOpenAICompatProxyStatuses(): Promise { + const profileNames = listOpenAICompatProxyProfileNames(); + const statuses = await Promise.all( + profileNames.map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) + ); + return statuses.filter((status) => status.profileName); +} + +export async function getOpenAICompatProxyStatus( + profileName?: string +): Promise { + if (profileName) { + return getOpenAICompatProxyStatusForProfile(profileName); + } + + const statuses = await listOpenAICompatProxyStatuses(); + const running = statuses.filter((status) => status.running); + if (running.length === 1) { + return running[0] || { running: false }; + } + if (running.length > 1) { + return { running: true }; + } + const latestKnown = statuses[0]; + return latestKnown ?? { running: false }; +} + +async function stopOpenAICompatProxyUnlocked( + profileName: string +): Promise<{ success: boolean; error?: string }> { + const pid = getOpenAICompatProxyPid(profileName); if (!pid) { - removeOpenAICompatProxySession(); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -170,7 +211,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro ); if (ownership === 'not-owned') { - removeOpenAICompatProxyPid(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -182,8 +224,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro } if (ownership === 'not-running') { - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -214,13 +256,32 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro } } - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } -export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> { - return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked()); +export async function stopOpenAICompatProxy( + profileName?: string +): Promise<{ success: boolean; error?: string }> { + return withOpenAICompatProxyLock(async () => { + cleanupLegacyOpenAICompatProxyState(); + if (profileName) { + return stopOpenAICompatProxyUnlocked(profileName); + } + + const statuses = await listOpenAICompatProxyStatuses(); + for (const status of statuses) { + if (!status.profileName) { + continue; + } + const stopped = await stopOpenAICompatProxyUnlocked(status.profileName); + if (!stopped.success) { + return stopped; + } + } + return { success: true }; + }); } export async function startOpenAICompatProxy( @@ -228,30 +289,28 @@ export async function startOpenAICompatProxy( options: { port?: number; host?: string; insecure?: boolean } = {} ): Promise { return withOpenAICompatProxyLock(async () => { - const status = await getOpenAICompatProxyStatus(); + cleanupLegacyOpenAICompatProxyState(); + const status = await getOpenAICompatProxyStatus(profile.profileName); const host = options.host?.trim() || status.host || '127.0.0.1'; - const port = + const preferredPort = typeof options.port === 'number' ? options.port - : status.running && status.profileName === profile.profileName && status.port - ? status.port - : await findOpenAICompatProxyPort(); + : status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName); + const port = + status.running && status.port && (status.host || '127.0.0.1') === host + ? status.port + : await findOpenAICompatProxyPortNear(preferredPort); if (port === 0) { return { success: false, - port: OPENAI_COMPAT_PROXY_DEFAULT_PORT, + port: preferredPort, error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, }; } if (!Number.isInteger(port) || port < 1 || port > 65535) { return { success: false, port, error: `Invalid port: ${port}` }; } - if ( - status.running && - status.profileName === profile.profileName && - status.port === port && - (status.host || '127.0.0.1') === host - ) { + if (status.running && status.port === port && (status.host || '127.0.0.1') === host) { return { success: true, alreadyRunning: true, @@ -261,15 +320,7 @@ export async function startOpenAICompatProxy( }; } if (status.running) { - if (status.profileName !== profile.profileName) { - return { - success: false, - port, - error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`, - }; - } - - const stopped = await stopOpenAICompatProxyUnlocked(); + const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); if (!stopped.success) { return { success: false, @@ -298,8 +349,8 @@ export async function startOpenAICompatProxy( resolved = true; if (timeout) clearTimeout(timeout); if (!result.success) { - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profile.profileName); + removeOpenAICompatProxySession(profile.profileName); } resolve(result); }; @@ -326,7 +377,7 @@ export async function startOpenAICompatProxy( proc.unref(); if (proc.pid) { - writeOpenAICompatProxyPid(proc.pid); + writeOpenAICompatProxyPid(profile.profileName, proc.pid); } writeOpenAICompatProxySession({ profileName: profile.profileName, diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts new file mode 100644 index 00000000..919e5755 --- /dev/null +++ b/src/proxy/proxy-port-resolver.ts @@ -0,0 +1,11 @@ +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; + +export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { + const config = loadOrCreateUnifiedConfig(); + const profilePort = config.proxy?.profile_ports?.[profileName]; + if (typeof profilePort === 'number') { + return profilePort; + } + return config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; +} From 8942be3afcff581476aa82161ec7ddce0aaea35c Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 13:12:56 +0900 Subject: [PATCH 02/21] test(proxy): cover multi-profile daemon ports --- .../proxy/daemon-lifecycle.test.ts | 26 +++++++++--- tests/unit/proxy/proxy-port-resolver.test.ts | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 tests/unit/proxy/proxy-port-resolver.test.ts diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 5a5aa86d..f6cb8e63 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -80,7 +80,7 @@ describe('openai proxy daemon lifecycle', () => { expect((await getOpenAICompatProxyStatus()).running).toBe(false); }, 35000); - it('refuses to replace a running proxy for a different profile', async () => { + it('allows different profiles to run on different ports', async () => { const firstPort = await getPort(); const firstSettingsPath = path.join(tempDir, 'hf.settings.json'); fs.writeFileSync( @@ -108,7 +108,20 @@ describe('openai proxy daemon lifecycle', () => { const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort }); expect(firstStart.success).toBe(true); - const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), { + const secondPort = await getPort(); + const secondSettingsPath = path.join(tempDir, 'openai.settings.json'); + fs.writeFileSync( + secondSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + const secondProfile = resolveOpenAICompatProfileConfig('openai', secondSettingsPath, { ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', ANTHROPIC_AUTH_TOKEN: 'sk-openai', ANTHROPIC_MODEL: 'gpt-4.1', @@ -117,11 +130,14 @@ describe('openai proxy daemon lifecycle', () => { throw new Error('Expected second OpenAI-compatible profile'); } - const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() }); - expect(secondStart.success).toBe(false); - expect(secondStart.error).toContain('Proxy already running for profile "hf"'); + const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort }); + expect(secondStart.success).toBe(true); + expect(secondStart.port).toBe(secondPort); const health = await fetch(`http://127.0.0.1:${firstPort}/health`); expect(health.status).toBe(200); + + const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`); + expect(secondHealth.status).toBe(200); }); }); diff --git a/tests/unit/proxy/proxy-port-resolver.test.ts b/tests/unit/proxy/proxy-port-resolver.test.ts new file mode 100644 index 00000000..c29d66c8 --- /dev/null +++ b/tests/unit/proxy/proxy-port-resolver.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-config-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('resolveOpenAICompatProxyPreferredPort', () => { + it('returns the configured profile-scoped port when present', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 3456, + profile_ports: { ccgm: 3461 }, + }; + }); + + expect(resolveOpenAICompatProxyPreferredPort('ccgm')).toBe(3461); + }); + + it('falls back to the shared default port when no profile mapping exists', () => { + expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(3456); + }); +}); From 46db668636fee238bc581848d35e5d4fc39a3081 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 12:50:03 +0900 Subject: [PATCH 03/21] chore(gitignore): ignore local agent override files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2a782db3..42ffcf79 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ package-lock.json .claude/active-plan .claude/agent-memory/ .claude/plans-registry.json* +CLAUDE.local.md +AGENTS.override.md # Logs directory logs/ @@ -43,4 +45,4 @@ logs/ .dev-release-info.json # Test coverage -ui/coverage/ \ No newline at end of file +ui/coverage/ From 630a493ccad8e3acac3559947eb5a8b362b7f446 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 13:28:25 +0900 Subject: [PATCH 04/21] fix(proxy): disambiguate activate without profile --- src/commands/proxy-command.ts | 30 ++++++++++++++----- src/proxy/proxy-daemon.ts | 2 +- tests/e2e/proxy-command.e2e.test.ts | 46 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 336472f0..5a2451c1 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -22,6 +22,20 @@ function parseOptionValue(args: string[], key: string): string | undefined { return withEquals ? withEquals.slice(prefix.length) : undefined; } +function findPositionalArg(args: string[], optionsWithValues: string[] = []): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg.startsWith('-')) { + if (optionsWithValues.includes(arg)) { + i += 1; + } + continue; + } + return arg; + } + return undefined; +} + function showHelp(): number { console.log('OpenAI-Compatible Proxy'); console.log(''); @@ -63,7 +77,7 @@ function resolveProfile(profileName: string) { } async function handleStart(args: string[]): Promise { - const profileName = args.find((arg) => !arg.startsWith('-')); + const profileName = findPositionalArg(args, ['--port', '--host']); if (!profileName) { console.error( fail('Usage: ccs proxy start [--port ] [--host ] [--insecure]') @@ -102,7 +116,7 @@ async function handleStart(args: string[]): Promise { } async function handleStatus(args: string[] = []): Promise { - const profileName = args.find((arg) => !arg.startsWith('-')); + const profileName = findPositionalArg(args); const running = profileName ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) : (await listOpenAICompatProxyStatuses()).filter((status) => status.running); @@ -130,12 +144,7 @@ async function handleStatus(args: string[] = []): Promise { } async function handleActivate(args: string[]): Promise { - const profileName = args.find((arg) => !arg.startsWith('-')); - const status = await getOpenAICompatProxyStatus(profileName); - if (!status.running || !status.profileName || !status.port || !status.authToken) { - console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); - return 1; - } + const profileName = findPositionalArg(args, ['--shell']); if (!profileName) { const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); if (running.length > 1) { @@ -145,6 +154,11 @@ async function handleActivate(args: string[]): Promise { return 1; } } + const status = await getOpenAICompatProxyStatus(profileName); + if (!status.running || !status.profileName || !status.port || !status.authToken) { + console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); + return 1; + } const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell')); let profile; diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index b2cf668c..ac3f3599 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -185,7 +185,7 @@ export async function getOpenAICompatProxyStatus( const statuses = await listOpenAICompatProxyStatuses(); const running = statuses.filter((status) => status.running); if (running.length === 1) { - return running[0] || { running: false }; + return running[0]; } if (running.length > 1) { return { running: true }; diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 021a0919..47029bb5 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -107,4 +107,50 @@ describe('proxy command e2e', () => { const stopped = runCli(['proxy', 'stop']); expect(stopped.status).toBe(0); }, 35000); + + it('requires an explicit profile when activating with multiple running proxies', async () => { + const firstPort = await getPort(); + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const firstSettingsPath = path.join(ccsDir, 'ccg.settings.json'); + const secondSettingsPath = path.join(ccsDir, 'ccgm.settings.json'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { ccg: firstSettingsPath, ccgm: secondSettingsPath } }, null, 2), + 'utf8' + ); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-ccg', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + fs.writeFileSync( + secondSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-ccgm', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + expect(runCli(['proxy', 'start', 'ccg', '--port', String(firstPort)]).status).toBe(0); + const secondPort = await getPort(); + expect(runCli(['proxy', 'start', 'ccgm', '--port', String(secondPort)]).status).toBe(0); + + const activate = runCli(['proxy', 'activate', '--shell', 'bash']); + expect(activate.status).toBe(1); + expect(activate.stderr).toContain( + 'Multiple proxies are running. Specify a profile: ccs proxy activate ' + ); + }, 35000); }); From 24c24847f7c981cc0654c2cd5a6310edca4609fe Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:03:12 +0900 Subject: [PATCH 05/21] fix(proxy): preserve legacy state and exact ports --- src/proxy/proxy-daemon-paths.ts | 8 + src/proxy/proxy-daemon-state.ts | 56 ++++-- src/proxy/proxy-daemon.ts | 103 ++++++++--- src/proxy/proxy-port-resolver.ts | 20 ++- .../proxy/daemon-lifecycle.test.ts | 163 ++++++++++++++++++ 5 files changed, 309 insertions(+), 41 deletions(-) diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 011a9f59..7119c2e9 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -25,3 +25,11 @@ export function getOpenAICompatProxySessionPath(profileName: string): string { `${getOpenAICompatProxyProfileKey(profileName)}.session.json` ); } + +export function getLegacyOpenAICompatProxyPidPath(): string { + return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +} + +export function getLegacyOpenAICompatProxySessionPath(): string { + return path.join(getOpenAICompatProxyDir(), 'session.json'); +} diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 82d11130..98899e47 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -2,6 +2,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { getOpenAICompatProxyDir, + getLegacyOpenAICompatProxyPidPath, + getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from './proxy-daemon-paths'; @@ -21,9 +23,9 @@ function ensureProxyDir(): void { fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); } -export function getOpenAICompatProxyPid(profileName: string): number | null { +function readPid(pidPath: string): number | null { try { - const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim(); + const raw = fs.readFileSync(pidPath, 'utf8').trim(); const pid = Number.parseInt(raw, 10); return Number.isInteger(pid) ? pid : null; } catch { @@ -31,6 +33,14 @@ export function getOpenAICompatProxyPid(profileName: string): number | null { } } +export function getOpenAICompatProxyPid(profileName: string): number | null { + return readPid(getOpenAICompatProxyPidPath(profileName)); +} + +export function getLegacyOpenAICompatProxyPid(): number | null { + return readPid(getLegacyOpenAICompatProxyPidPath()); +} + export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); @@ -44,16 +54,30 @@ export function removeOpenAICompatProxyPid(profileName: string): void { } } -export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { +export function removeLegacyOpenAICompatProxyPid(): void { try { - return JSON.parse( - fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8') - ) as OpenAICompatProxySession; + fs.unlinkSync(getLegacyOpenAICompatProxyPidPath()); + } catch { + // Best-effort cleanup. + } +} + +function readSession(sessionPath: string): OpenAICompatProxySession | null { + try { + return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession; } catch { return null; } } +export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { + return readSession(getOpenAICompatProxySessionPath(profileName)); +} + +export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null { + return readSession(getLegacyOpenAICompatProxySessionPath()); +} + export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); fs.writeFileSync( @@ -71,6 +95,14 @@ export function removeOpenAICompatProxySession(profileName: string): void { } } +export function removeLegacyOpenAICompatProxySession(): void { + try { + fs.unlinkSync(getLegacyOpenAICompatProxySessionPath()); + } catch { + // Best-effort cleanup. + } +} + export function listOpenAICompatProxyProfileNames(): string[] { try { const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); @@ -87,18 +119,6 @@ export function listOpenAICompatProxyProfileNames(): string[] { } } -export function cleanupLegacyOpenAICompatProxyState(): void { - ensureProxyDir(); - const legacyNames = ['daemon.pid', 'session.json']; - for (const legacyName of legacyNames) { - try { - fs.unlinkSync(path.join(getOpenAICompatProxyDir(), legacyName)); - } catch { - // Best-effort cleanup. - } - } -} - export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index ac3f3599..d26c8e3c 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -12,10 +12,13 @@ import { getOpenAICompatProxyDir, } from './proxy-daemon-paths'; import { + getLegacyOpenAICompatProxyPid, getOpenAICompatProxyPid, listOpenAICompatProxyProfileNames, + readLegacyOpenAICompatProxySession, readOpenAICompatProxySession, - cleanupLegacyOpenAICompatProxyState, + removeLegacyOpenAICompatProxyPid, + removeLegacyOpenAICompatProxySession, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, @@ -23,7 +26,7 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; -import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver'; +import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -111,6 +114,12 @@ async function findOpenAICompatProxyPortNear(preferredPort: number): Promise { for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { try { @@ -157,20 +166,51 @@ export async function isOpenAICompatProxyRunning(port: number): Promise async function getOpenAICompatProxyStatusForProfile( profileName: string ): Promise { - const session = readOpenAICompatProxySession(profileName); - const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + const state = getOpenAICompatProxyStateForProfile(profileName); + const session = state.session; + if (!session) { + return { running: false }; + } + + const port = session.port; const running = await isOpenAICompatProxyRunning(port); return { running, - pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined, + pid: running ? state.pid || undefined : undefined, ...session, }; } +function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord { + const session = readOpenAICompatProxySession(profileName); + if (session) { + return { + pid: getOpenAICompatProxyPid(profileName), + session, + source: 'profile', + }; + } + + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName === profileName) { + return { + pid: getLegacyOpenAICompatProxyPid(), + session: legacySession, + source: 'legacy', + }; + } + + return { pid: null, session: null, source: 'profile' }; +} + export async function listOpenAICompatProxyStatuses(): Promise { - const profileNames = listOpenAICompatProxyProfileNames(); + const profileNames = new Set(listOpenAICompatProxyProfileNames()); + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName) { + profileNames.add(legacySession.profileName); + } const statuses = await Promise.all( - profileNames.map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) + [...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) ); return statuses.filter((status) => status.profileName); } @@ -197,9 +237,10 @@ export async function getOpenAICompatProxyStatus( async function stopOpenAICompatProxyUnlocked( profileName: string ): Promise<{ success: boolean; error?: string }> { - const pid = getOpenAICompatProxyPid(profileName); + const state = getOpenAICompatProxyStateForProfile(profileName); + const pid = state.pid; if (!pid) { - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -211,8 +252,7 @@ async function stopOpenAICompatProxyUnlocked( ); if (ownership === 'not-owned') { - removeOpenAICompatProxyPid(profileName); - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -224,8 +264,7 @@ async function stopOpenAICompatProxyUnlocked( } if (ownership === 'not-running') { - removeOpenAICompatProxyPid(profileName); - removeOpenAICompatProxySession(profileName); + removeOpenAICompatProxyState(state, profileName); return { success: true }; } @@ -256,16 +295,28 @@ async function stopOpenAICompatProxyUnlocked( } } + removeOpenAICompatProxyState(state, profileName); + return { success: true }; +} + +function removeOpenAICompatProxyState( + state: OpenAICompatProxyStateRecord, + profileName: string +): void { + if (state.source === 'legacy') { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return; + } + removeOpenAICompatProxyPid(profileName); removeOpenAICompatProxySession(profileName); - return { success: true }; } export async function stopOpenAICompatProxy( profileName?: string ): Promise<{ success: boolean; error?: string }> { return withOpenAICompatProxyLock(async () => { - cleanupLegacyOpenAICompatProxyState(); if (profileName) { return stopOpenAICompatProxyUnlocked(profileName); } @@ -289,17 +340,22 @@ export async function startOpenAICompatProxy( options: { port?: number; host?: string; insecure?: boolean } = {} ): Promise { return withOpenAICompatProxyLock(async () => { - cleanupLegacyOpenAICompatProxyState(); const status = await getOpenAICompatProxyStatus(profile.profileName); const host = options.host?.trim() || status.host || '127.0.0.1'; + const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName); + const explicitPort = typeof options.port === 'number' ? options.port : undefined; const preferredPort = - typeof options.port === 'number' - ? options.port - : status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName); + explicitPort ?? + (portPreference.source === 'profile' + ? portPreference.port + : status.port || portPreference.port); + const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile'; const port = status.running && status.port && (status.host || '127.0.0.1') === host ? status.port - : await findOpenAICompatProxyPortNear(preferredPort); + : requiresExactPort + ? preferredPort + : await findOpenAICompatProxyPortNear(preferredPort); if (port === 0) { return { success: false, @@ -319,6 +375,13 @@ export async function startOpenAICompatProxy( authToken: status.authToken, }; } + if (requiresExactPort && (await isPortOccupied(port))) { + return { + success: false, + port, + error: `Requested proxy port ${port} is already in use`, + }; + } if (status.running) { const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); if (!stopped.success) { diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts index 919e5755..418ca721 100644 --- a/src/proxy/proxy-port-resolver.ts +++ b/src/proxy/proxy-port-resolver.ts @@ -1,11 +1,25 @@ import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; -export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { +export interface OpenAICompatProxyPortPreference { + port: number; + source: 'default' | 'profile'; +} + +export function resolveOpenAICompatProxyPortPreference( + profileName: string +): OpenAICompatProxyPortPreference { const config = loadOrCreateUnifiedConfig(); const profilePort = config.proxy?.profile_ports?.[profileName]; if (typeof profilePort === 'number') { - return profilePort; + return { port: profilePort, source: 'profile' }; } - return config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + return { + port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT, + source: 'default', + }; +} + +export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { + return resolveOpenAICompatProxyPortPreference(profileName).port; } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index f6cb8e63..9ff9e5f6 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -9,6 +9,11 @@ import { stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; +import { + getLegacyOpenAICompatProxyPidPath, + getLegacyOpenAICompatProxySessionPath, +} from '../../../src/proxy/proxy-daemon-paths'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; let originalCcsHome: string | undefined; let tempDir: string; @@ -140,4 +145,162 @@ describe('openai proxy daemon lifecycle', () => { const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`); expect(secondHealth.status).toBe(200); }); + + it('keeps a legacy singleton daemon visible across upgrade', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'legacy.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a legacy OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.pid).toBeDefined(); + + const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath()); + fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8'); + fs.writeFileSync( + getLegacyOpenAICompatProxySessionPath(), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port, + baseUrl: profile.baseUrl, + authToken: started.authToken, + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + fs.rmSync(path.join(proxyDir, 'legacy.daemon.pid'), { force: true }); + fs.rmSync(path.join(proxyDir, 'legacy.session.json'), { force: true }); + + const status = await getOpenAICompatProxyStatus('legacy'); + expect(status.running).toBe(true); + expect(status.port).toBe(port); + + const restarted = await startOpenAICompatProxy(profile); + expect(restarted.success).toBe(true); + expect(restarted.alreadyRunning).toBe(true); + expect(restarted.port).toBe(port); + + const stopped = await stopOpenAICompatProxy('legacy'); + expect(stopped.success).toBe(true); + expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false); + expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false); + }, 35000); + + it('fails when an explicit port is already occupied', async () => { + const occupiedPort = await getPort(); + const server = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + const settingsPath = path.join(tempDir, 'occupied-explicit.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-explicit', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('explicit', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-explicit', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an explicit-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port: occupiedPort }); + expect(started.success).toBe(false); + expect(started.port).toBe(occupiedPort); + expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`); + } finally { + server.stop(true); + } + }); + + it('fails when a configured profile port is already occupied', async () => { + const occupiedPort = await getPort(); + const server = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + profile_ports: { mapped: occupiedPort }, + }; + }); + + const settingsPath = path.join(tempDir, 'occupied-mapped.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-mapped', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('mapped', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-mapped', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a mapped-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(false); + expect(started.port).toBe(occupiedPort); + expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`); + } finally { + server.stop(true); + } + }); }); From f345cf441e474e1fecbef95fbff8c95351079721 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:09:31 +0900 Subject: [PATCH 06/21] fix(proxy): ignore legacy singleton session file --- src/proxy/proxy-daemon-state.ts | 12 +++++-- tests/unit/proxy/proxy-daemon-state.test.ts | 37 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/unit/proxy/proxy-daemon-state.test.ts diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 98899e47..fd05c12c 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -108,10 +108,18 @@ export function listOpenAICompatProxyProfileNames(): string[] { const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); const profileKeys = new Set(); for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.session.json')) { + if ( + !entry.isFile() || + entry.name === 'session.json' || + !entry.name.endsWith('.session.json') + ) { continue; } - profileKeys.add(entry.name.slice(0, -'.session.json'.length)); + const profileKey = entry.name.slice(0, -'.session.json'.length); + if (!profileKey) { + continue; + } + profileKeys.add(profileKey); } return [...profileKeys].map((profileKey) => decodeURIComponent(profileKey)); } catch { diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts new file mode 100644 index 00000000..01f9d7c0 --- /dev/null +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + getLegacyOpenAICompatProxySessionPath, + getOpenAICompatProxySessionPath, +} from '../../../src/proxy/proxy-daemon-paths'; +import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-state-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('listOpenAICompatProxyProfileNames', () => { + it('ignores the legacy singleton session file', () => { + fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true }); + fs.writeFileSync(getLegacyOpenAICompatProxySessionPath(), '{}\n', 'utf8'); + fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8'); + + expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); + }); +}); From 02747edb7266e3e260fd09a15ec28b78340d8c2c Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:15:00 +0900 Subject: [PATCH 07/21] fix(ci): validate cached ui dependencies --- .github/workflows/ci.yml | 12 +++--------- .github/workflows/dev-release.yml | 4 +--- .github/workflows/release.yml | 4 +--- scripts/ensure-deps.sh | 26 ++++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 scripts/ensure-deps.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b45e544..7103da86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,7 @@ jobs: ${{ runner.os }}-bun- - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Run ${{ matrix.check.name }} run: ${{ matrix.check.cmd }} @@ -88,9 +86,7 @@ jobs: ${{ runner.os }}-bun- - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Build run: bun run build:all @@ -133,9 +129,7 @@ jobs: ${{ runner.os }}-bun- - name: Ensure dependencies - run: | - [ -d node_modules ] || bun install --frozen-lockfile - [ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile) + run: bash scripts/ensure-deps.sh - name: Download dist artifact uses: actions/download-artifact@v4 diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index e048677e..cf9f59d7 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -41,9 +41,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install dependencies - run: | - bun install --frozen-lockfile - cd ui && bun install --frozen-lockfile + run: bash scripts/ensure-deps.sh - name: Build run: bun run build:all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0e2220..adb7a3e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,7 @@ jobs: bun-version: '1.3.9' - name: Install dependencies - run: | - bun install --frozen-lockfile - cd ui && bun install --frozen-lockfile + run: bash scripts/ensure-deps.sh - name: Build package run: bun run build:all diff --git a/scripts/ensure-deps.sh b/scripts/ensure-deps.sh new file mode 100644 index 00000000..77a0914e --- /dev/null +++ b/scripts/ensure-deps.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UI_DIR="$ROOT_DIR/ui" +UI_SENTINEL="$UI_DIR/node_modules/@date-fns/tz/date/mini.js" + +ensure_root_deps() { + if [[ ! -d "$ROOT_DIR/node_modules" ]]; then + echo "[deps] Installing root dependencies" + (cd "$ROOT_DIR" && bun install --frozen-lockfile) + fi +} + +ensure_ui_deps() { + if [[ -d "$UI_DIR/node_modules" && -f "$UI_SENTINEL" ]]; then + return + fi + + echo "[deps] Reinstalling UI dependencies" + rm -rf "$UI_DIR/node_modules" + (cd "$UI_DIR" && bun install --frozen-lockfile) +} + +ensure_root_deps +ensure_ui_deps From f4f33b0b5f10c62d692ff81a9d444898e5db867c Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:27:02 +0900 Subject: [PATCH 08/21] fix(ci): rely on idempotent dependency sync --- scripts/ensure-deps.sh | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/scripts/ensure-deps.sh b/scripts/ensure-deps.sh index 77a0914e..384a0983 100644 --- a/scripts/ensure-deps.sh +++ b/scripts/ensure-deps.sh @@ -3,22 +3,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" UI_DIR="$ROOT_DIR/ui" -UI_SENTINEL="$UI_DIR/node_modules/@date-fns/tz/date/mini.js" ensure_root_deps() { - if [[ ! -d "$ROOT_DIR/node_modules" ]]; then - echo "[deps] Installing root dependencies" - (cd "$ROOT_DIR" && bun install --frozen-lockfile) - fi + echo "[deps] Syncing root dependencies" + (cd "$ROOT_DIR" && bun install --frozen-lockfile) } ensure_ui_deps() { - if [[ -d "$UI_DIR/node_modules" && -f "$UI_SENTINEL" ]]; then - return - fi - - echo "[deps] Reinstalling UI dependencies" - rm -rf "$UI_DIR/node_modules" + echo "[deps] Syncing UI dependencies" (cd "$UI_DIR" && bun install --frozen-lockfile) } From 2a80f7b1c193b839bcf4af691ae5b84ac6f44fd5 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:27:09 +0900 Subject: [PATCH 09/21] fix(proxy): handle end-of-options parsing --- src/commands/proxy-command.ts | 8 +++++++- tests/unit/commands/proxy-command.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/unit/commands/proxy-command.test.ts diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 5a2451c1..2aa9d391 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -22,9 +22,15 @@ function parseOptionValue(args: string[], key: string): string | undefined { return withEquals ? withEquals.slice(prefix.length) : undefined; } -function findPositionalArg(args: string[], optionsWithValues: string[] = []): string | undefined { +export function findPositionalArg( + args: string[], + optionsWithValues: string[] = [] +): string | undefined { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; + if (arg === '--') { + return args[i + 1]; + } if (arg.startsWith('-')) { if (optionsWithValues.includes(arg)) { i += 1; diff --git a/tests/unit/commands/proxy-command.test.ts b/tests/unit/commands/proxy-command.test.ts new file mode 100644 index 00000000..ff71eb90 --- /dev/null +++ b/tests/unit/commands/proxy-command.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'bun:test'; +import { findPositionalArg } from '../../../src/commands/proxy-command'; + +describe('findPositionalArg', () => { + it('skips option values before returning the first positional argument', () => { + expect(findPositionalArg(['--port', '3456', 'ccg'], ['--port'])).toBe('ccg'); + }); + + it('treats arguments after -- as positional', () => { + expect(findPositionalArg(['--', '--port', '3456'], ['--port'])).toBe('--port'); + }); +}); From 7cf555356aa1d8b42c7860cc924381d4cc81aecb Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:33:44 +0900 Subject: [PATCH 10/21] fix(proxy): retry raced dynamic port binds --- src/commands/proxy-command.ts | 2 +- src/proxy/proxy-daemon.ts | 268 +++++++++++++++++++--------------- 2 files changed, 151 insertions(+), 119 deletions(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 2aa9d391..88365c4f 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -199,7 +199,7 @@ export async function handleProxyCommand(args: string[]): Promise { case 'start': return handleStart(args.slice(1)); case 'stop': { - const profileName = args[1] && !args[1]?.startsWith('-') ? args[1] : undefined; + const profileName = args[1] && !args[1].startsWith('-') ? args[1] : undefined; const result = await stopOpenAICompatProxy(profileName); if (!result.success) { console.error(fail(result.error || 'Failed to stop proxy')); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index d26c8e3c..b2d30fe1 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -93,12 +93,17 @@ async function isPortOccupied(port: number): Promise { }); } -async function findOpenAICompatProxyPort(): Promise { +async function findOpenAICompatProxyPort( + excludedPorts: ReadonlySet = new Set() +): Promise { for ( let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT; candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10; candidate += 1 ) { + if (excludedPorts.has(candidate)) { + continue; + } if (!(await isPortOccupied(candidate))) { return candidate; } @@ -107,11 +112,14 @@ async function findOpenAICompatProxyPort(): Promise { return 0; } -async function findOpenAICompatProxyPortNear(preferredPort: number): Promise { - if (!(await isPortOccupied(preferredPort))) { +async function findOpenAICompatProxyPortNear( + preferredPort: number, + excludedPorts: ReadonlySet = new Set() +): Promise { + if (!excludedPorts.has(preferredPort) && !(await isPortOccupied(preferredPort))) { return preferredPort; } - return findOpenAICompatProxyPort(); + return findOpenAICompatProxyPort(excludedPorts); } interface OpenAICompatProxyStateRecord { @@ -350,36 +358,23 @@ export async function startOpenAICompatProxy( ? portPreference.port : status.port || portPreference.port); const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile'; - const port = - status.running && status.port && (status.host || '127.0.0.1') === host - ? status.port - : requiresExactPort - ? preferredPort - : await findOpenAICompatProxyPortNear(preferredPort); - if (port === 0) { - return { - success: false, - port: preferredPort, - error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, - }; - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - return { success: false, port, error: `Invalid port: ${port}` }; - } - if (status.running && status.port === port && (status.host || '127.0.0.1') === host) { + if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) { return { success: true, alreadyRunning: true, pid: status.pid, - port, + port: preferredPort, authToken: status.authToken, }; } - if (requiresExactPort && (await isPortOccupied(port))) { + if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) { + return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` }; + } + if (requiresExactPort && (await isPortOccupied(preferredPort))) { return { success: false, - port, - error: `Requested proxy port ${port} is already in use`, + port: preferredPort, + error: `Requested proxy port ${preferredPort} is already in use`, }; } if (status.running) { @@ -387,7 +382,7 @@ export async function startOpenAICompatProxy( if (!stopped.success) { return { success: false, - port, + port: preferredPort, error: stopped.error || 'Failed to restart the running proxy', }; } @@ -397,107 +392,144 @@ export async function startOpenAICompatProxy( if (!daemonEntry) { return { success: false, - port, + port: preferredPort, error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.', }; } - return new Promise((resolve) => { - let resolved = false; - let timeout: NodeJS.Timeout | null = null; - const authToken = generateProxyAuthToken(); + const startOnPort = (port: number): Promise => + new Promise((resolve) => { + let resolved = false; + let timeout: NodeJS.Timeout | null = null; + const authToken = generateProxyAuthToken(); - const finish = (result: StartOpenAICompatProxyResult) => { - if (resolved) return; - resolved = true; - if (timeout) clearTimeout(timeout); - if (!result.success) { - removeOpenAICompatProxyPid(profile.profileName); - removeOpenAICompatProxySession(profile.profileName); + const finish = (result: StartOpenAICompatProxyResult) => { + if (resolved) return; + resolved = true; + if (timeout) clearTimeout(timeout); + if (!result.success) { + removeOpenAICompatProxyPid(profile.profileName); + removeOpenAICompatProxySession(profile.profileName); + } + resolve(result); + }; + + const proc: ChildProcess = spawn( + process.execPath, + [ + daemonEntry, + '--port', + String(port), + '--host', + host, + '--profile', + profile.profileName, + '--settings-path', + profile.settingsPath, + '--auth-token', + authToken, + ...(options.insecure ? ['--insecure'] : []), + '--ccs-openai-proxy-daemon', + ], + { stdio: 'ignore', detached: true } + ); + + proc.unref(); + if (proc.pid) { + writeOpenAICompatProxyPid(profile.profileName, proc.pid); } - resolve(result); - }; - - const proc: ChildProcess = spawn( - process.execPath, - [ - daemonEntry, - '--port', - String(port), - '--host', + writeOpenAICompatProxySession({ + profileName: profile.profileName, + settingsPath: profile.settingsPath, host, - '--profile', - profile.profileName, - '--settings-path', - profile.settingsPath, - '--auth-token', - authToken, - ...(options.insecure ? ['--insecure'] : []), - '--ccs-openai-proxy-daemon', - ], - { stdio: 'ignore', detached: true } - ); - - proc.unref(); - if (proc.pid) { - writeOpenAICompatProxyPid(profile.profileName, proc.pid); - } - writeOpenAICompatProxySession({ - profileName: profile.profileName, - settingsPath: profile.settingsPath, - host, - port, - baseUrl: profile.baseUrl, - authToken, - model: profile.model, - insecure: options.insecure, - }); - - let attempts = 0; - const poll = async () => { - attempts += 1; - if (await isOpenAICompatProxyRunning(port)) { - finish({ success: true, pid: proc.pid, port, authToken }); - return; - } - if (attempts >= 30) { - finish({ - success: false, - port, - error: `Proxy daemon did not start within 30 seconds on port ${port}`, - }); - return; - } - timeout = setTimeout(poll, 1000); - }; - - timeout = setTimeout(poll, 1000); - proc.on('error', (error) => { - finish({ success: false, port, error: error.message }); - }); - proc.on('exit', (code, signal) => { - if (code === 0) { - finish({ - success: false, - port, - error: 'Proxy daemon exited before becoming healthy', - }); - return; - } - if (code !== null) { - finish({ - success: false, - port, - error: `Proxy daemon exited with code ${code}`, - }); - return; - } - finish({ - success: false, port, - error: `Proxy daemon was killed by signal ${signal}`, + baseUrl: profile.baseUrl, + authToken, + model: profile.model, + insecure: options.insecure, + }); + + let attempts = 0; + const poll = async () => { + attempts += 1; + if (await isOpenAICompatProxyRunning(port)) { + finish({ success: true, pid: proc.pid, port, authToken }); + return; + } + if (attempts >= 30) { + finish({ + success: false, + port, + error: `Proxy daemon did not start within 30 seconds on port ${port}`, + }); + return; + } + timeout = setTimeout(poll, 1000); + }; + + timeout = setTimeout(poll, 1000); + proc.on('error', (error) => { + finish({ success: false, port, error: error.message }); + }); + proc.on('exit', (code, signal) => { + if (code === 0) { + finish({ + success: false, + port, + error: 'Proxy daemon exited before becoming healthy', + }); + return; + } + if (code !== null) { + finish({ + success: false, + port, + error: `Proxy daemon exited with code ${code}`, + }); + return; + } + finish({ + success: false, + port, + error: `Proxy daemon was killed by signal ${signal}`, + }); }); }); - }); + + if (requiresExactPort) { + return startOnPort(preferredPort); + } + + const attemptedPorts = new Set(); + let lastResult: StartOpenAICompatProxyResult | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); + if (port === 0) { + return { + success: false, + port: preferredPort, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + + const result = await startOnPort(port); + if (result.success) { + return result; + } + + lastResult = result; + attemptedPorts.add(port); + if (!(await isPortOccupied(port))) { + return result; + } + } + + return ( + lastResult ?? { + success: false, + port: preferredPort, + error: 'Failed to start proxy', + } + ); }); } From dfd070c9c6074424e1ce911c7a004547ce66d05f Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:42:19 +0900 Subject: [PATCH 11/21] fix(proxy): honor subcommand help flags --- src/commands/proxy-command.ts | 16 ++++++++++++++++ tests/e2e/proxy-command.e2e.test.ts | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 88365c4f..29b41c87 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -22,6 +22,10 @@ function parseOptionValue(args: string[], key: string): string | undefined { return withEquals ? withEquals.slice(prefix.length) : undefined; } +function hasHelpFlag(args: string[]): boolean { + return args.includes('--help') || args.includes('-h'); +} + export function findPositionalArg( args: string[], optionsWithValues: string[] = [] @@ -83,6 +87,9 @@ function resolveProfile(profileName: string) { } async function handleStart(args: string[]): Promise { + if (hasHelpFlag(args)) { + return showHelp(); + } const profileName = findPositionalArg(args, ['--port', '--host']); if (!profileName) { console.error( @@ -122,6 +129,9 @@ async function handleStart(args: string[]): Promise { } async function handleStatus(args: string[] = []): Promise { + if (hasHelpFlag(args)) { + return showHelp(); + } const profileName = findPositionalArg(args); const running = profileName ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) @@ -150,6 +160,9 @@ async function handleStatus(args: string[] = []): Promise { } async function handleActivate(args: string[]): Promise { + if (hasHelpFlag(args)) { + return showHelp(); + } const profileName = findPositionalArg(args, ['--shell']); if (!profileName) { const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); @@ -199,6 +212,9 @@ export async function handleProxyCommand(args: string[]): Promise { case 'start': return handleStart(args.slice(1)); case 'stop': { + if (hasHelpFlag(args.slice(1))) { + return showHelp(); + } const profileName = args[1] && !args[1].startsWith('-') ? args[1] : undefined; const result = await stopOpenAICompatProxy(profileName); if (!result.success) { diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 47029bb5..8c2a0f62 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -45,6 +45,13 @@ afterEach(() => { }); describe('proxy command e2e', () => { + it('shows help for subcommand help flags without executing the subcommand', () => { + const help = runCli(['proxy', 'stop', '--help']); + expect(help.status).toBe(0); + expect(help.stdout).toContain('Usage: ccs proxy [profile] [options]'); + expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)'); + }); + it('starts, reports status, activates, and stops via the built CLI', async () => { const port = await getPort(); const ccsDir = path.join(tempDir, '.ccs'); From db32d15d86316d07f1325443e218a1a642135219 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:51:32 +0900 Subject: [PATCH 12/21] fix(proxy): preserve running daemons on restart failure --- src/commands/proxy-command.ts | 52 ++++-- src/proxy/proxy-daemon.ts | 160 +++++++++++++----- tests/e2e/proxy-command.e2e.test.ts | 28 +++ .../proxy/daemon-lifecycle.test.ts | 49 ++++++ 4 files changed, 233 insertions(+), 56 deletions(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 29b41c87..c3dca91d 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -133,29 +133,55 @@ async function handleStatus(args: string[] = []): Promise { return showHelp(); } const profileName = findPositionalArg(args); - const running = profileName - ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) - : (await listOpenAICompatProxyStatuses()).filter((status) => status.running); - if (running.length === 0) { - console.log(info('Proxy is not running')); - return 0; - } - - for (const status of running) { - console.log(ok(`Proxy running on port ${status.port}`)); - if (status.host) { + const printStatus = (status: Awaited>) => { + console.log( + status.running + ? ok(`Proxy running on port ${status.port}`) + : info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`) + ); + if (status.host && status.port) { console.log(` Host: ${status.host}`); console.log(` Local URL: http://${status.host}:${status.port}`); } - console.log(` Profile: ${status.profileName}`); - console.log(` Base URL: ${status.baseUrl}`); + if (status.profileName) { + console.log(` Profile: ${status.profileName}`); + } + if (status.baseUrl) { + console.log(` Base URL: ${status.baseUrl}`); + } if (status.model) { console.log(` Model: ${status.model}`); } if (status.pid) { console.log(` PID: ${status.pid}`); } + }; + + if (profileName) { + const status = await getOpenAICompatProxyStatus(profileName); + if (!status.running && !status.profileName) { + console.log(info('Proxy is not running')); + return 0; + } + printStatus(status); + return 0; } + + const status = await getOpenAICompatProxyStatus(); + if (!status.running && !status.profileName) { + console.log(info('Proxy is not running')); + return 0; + } + + if (status.running && !status.profileName) { + const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); + for (const entry of running) { + printStatus(entry); + } + return 0; + } + + printStatus(status); return 0; } diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index b2d30fe1..427e442a 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -42,6 +42,11 @@ export interface StartOpenAICompatProxyResult { error?: string; } +interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult { + commitState?: () => void; + stop?: () => Promise; +} + function generateProxyAuthToken(): string { return crypto.randomBytes(24).toString('hex'); } @@ -93,6 +98,30 @@ async function isPortOccupied(port: number): Promise { }); } +async function terminateDaemonProcess(pid?: number): Promise { + if (!pid) { + return; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 200)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + return; + } + } + + process.kill(pid, 'SIGKILL'); + } catch { + // Best-effort cleanup for a daemon we just spawned. + } +} + async function findOpenAICompatProxyPort( excludedPorts: ReadonlySet = new Set() ): Promise { @@ -397,17 +426,35 @@ export async function startOpenAICompatProxy( }; } - const startOnPort = (port: number): Promise => + const launchOnPort = ( + port: number, + persistState: boolean + ): Promise => new Promise((resolve) => { let resolved = false; let timeout: NodeJS.Timeout | null = null; const authToken = generateProxyAuthToken(); + const commitState = () => { + if (proc.pid) { + writeOpenAICompatProxyPid(profile.profileName, proc.pid); + } + writeOpenAICompatProxySession({ + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host, + port, + baseUrl: profile.baseUrl, + authToken, + model: profile.model, + insecure: options.insecure, + }); + }; - const finish = (result: StartOpenAICompatProxyResult) => { + const finish = (result: OpenAICompatProxyLaunchResult) => { if (resolved) return; resolved = true; if (timeout) clearTimeout(timeout); - if (!result.success) { + if (!result.success && persistState) { removeOpenAICompatProxyPid(profile.profileName); removeOpenAICompatProxySession(profile.profileName); } @@ -435,25 +482,28 @@ export async function startOpenAICompatProxy( ); proc.unref(); - if (proc.pid) { - writeOpenAICompatProxyPid(profile.profileName, proc.pid); + if (persistState) { + commitState(); } - writeOpenAICompatProxySession({ - profileName: profile.profileName, - settingsPath: profile.settingsPath, - host, - port, - baseUrl: profile.baseUrl, - authToken, - model: profile.model, - insecure: options.insecure, - }); let attempts = 0; const poll = async () => { attempts += 1; if (await isOpenAICompatProxyRunning(port)) { - finish({ success: true, pid: proc.pid, port, authToken }); + finish({ + success: true, + pid: proc.pid, + port, + authToken, + ...(persistState + ? {} + : { + commitState, + stop: async () => { + await terminateDaemonProcess(proc.pid); + }, + }), + }); return; } if (attempts >= 30) { @@ -496,40 +546,64 @@ export async function startOpenAICompatProxy( }); }); - if (requiresExactPort) { - return startOnPort(preferredPort); - } + const launchProxy = async (persistState: boolean): Promise => { + if (requiresExactPort) { + return launchOnPort(preferredPort, persistState); + } - const attemptedPorts = new Set(); - let lastResult: StartOpenAICompatProxyResult | null = null; - for (let attempt = 0; attempt < 3; attempt += 1) { - const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); - if (port === 0) { - return { + const attemptedPorts = new Set(); + let lastResult: OpenAICompatProxyLaunchResult | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); + if (port === 0) { + return { + success: false, + port: preferredPort, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + + const result = await launchOnPort(port, persistState); + if (result.success) { + return result; + } + + lastResult = result; + attemptedPorts.add(port); + if (!(await isPortOccupied(port))) { + return result; + } + } + + return ( + lastResult ?? { success: false, port: preferredPort, - error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + error: 'Failed to start proxy', + } + ); + }; + + if (status.running) { + const launched = await launchProxy(false); + if (!launched.success) { + return launched; + } + + const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); + if (!stopped.success) { + await launched.stop?.(); + return { + success: false, + port: launched.port, + error: stopped.error || 'Failed to replace the running proxy', }; } - const result = await startOnPort(port); - if (result.success) { - return result; - } - - lastResult = result; - attemptedPorts.add(port); - if (!(await isPortOccupied(port))) { - return result; - } + launched.commitState?.(); + return launched; } - return ( - lastResult ?? { - success: false, - port: preferredPort, - error: 'Failed to start proxy', - } - ); + return launchProxy(true); }); } diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 8c2a0f62..ff86ad5a 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -52,6 +52,34 @@ describe('proxy command e2e', () => { expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)'); }); + it('shows the last-known proxy state when no proxy is currently running', async () => { + const stalePort = await getPort(); + const proxyDir = path.join(tempDir, '.ccs', 'proxy'); + fs.mkdirSync(proxyDir, { recursive: true }); + fs.writeFileSync( + path.join(proxyDir, 'stale.session.json'), + JSON.stringify( + { + profileName: 'stale', + settingsPath: path.join(tempDir, '.ccs', 'stale.settings.json'), + host: '127.0.0.1', + port: stalePort, + baseUrl: 'http://127.0.0.1:11434', + authToken: 'deadbeef', + model: 'qwen3-coder', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const status = runCli(['proxy', 'status']); + expect(status.status).toBe(0); + expect(status.stdout).toContain(`Proxy is not running (last known port ${stalePort})`); + expect(status.stdout).toContain('Profile: stale'); + }); + it('starts, reports status, activates, and stops via the built CLI', async () => { const port = await getPort(); const ccsDir = path.join(tempDir, '.ccs'); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 9ff9e5f6..3900f8d7 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -303,4 +303,53 @@ describe('openai proxy daemon lifecycle', () => { server.stop(true); } }); + + it('keeps the existing proxy running if replacement startup fails', async () => { + const firstPort = await getPort(); + const occupiedPort = await getPort(); + const busyServer = Bun.serve({ + port: occupiedPort, + hostname: '127.0.0.1', + fetch: () => new Response('busy'), + }); + + try { + const settingsPath = path.join(tempDir, 'rollback.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-rollback', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('rollback', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-rollback', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a rollback OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(profile, { port: firstPort }); + expect(firstStart.success).toBe(true); + + const restarted = await startOpenAICompatProxy(profile, { port: occupiedPort }); + expect(restarted.success).toBe(false); + + const status = await getOpenAICompatProxyStatus('rollback'); + expect(status.running).toBe(true); + expect(status.port).toBe(firstPort); + expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200); + } finally { + busyServer.stop(true); + } + }); }); From 8ad2763942d92ab665d7a2c742ca94ec969eac32 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 14:59:59 +0900 Subject: [PATCH 13/21] fix(proxy): retry candidate ports on bind conflict --- src/proxy/proxy-daemon-state.ts | 8 +- src/proxy/proxy-daemon.ts | 138 ++++++++++---------- tests/unit/proxy/proxy-daemon-state.test.ts | 8 ++ 3 files changed, 85 insertions(+), 69 deletions(-) diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index fd05c12c..d5610e69 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -121,7 +121,13 @@ export function listOpenAICompatProxyProfileNames(): string[] { } profileKeys.add(profileKey); } - return [...profileKeys].map((profileKey) => decodeURIComponent(profileKey)); + return [...profileKeys].flatMap((profileKey) => { + try { + return [decodeURIComponent(profileKey)]; + } catch { + return []; + } + }); } catch { return []; } diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 427e442a..2a72f15d 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -2,7 +2,6 @@ import { spawn, type ChildProcess } from 'child_process'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as http from 'http'; -import * as net from 'net'; import * as lockfile from 'proper-lockfile'; import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; import type { OpenAICompatProfileConfig } from './profile-router'; @@ -45,6 +44,7 @@ export interface StartOpenAICompatProxyResult { interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult { commitState?: () => void; stop?: () => Promise; + bindConflict?: boolean; } function generateProxyAuthToken(): string { @@ -81,23 +81,6 @@ async function withOpenAICompatProxyLock(operation: () => Promise): Promis } } -async function isPortOccupied(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: '127.0.0.1', port }); - const finish = (occupied: boolean) => { - socket.removeAllListeners(); - socket.destroy(); - resolve(occupied); - }; - - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - socket.setTimeout(500, () => { - finish(false); - }); - }); -} - async function terminateDaemonProcess(pid?: number): Promise { if (!pid) { return; @@ -122,33 +105,47 @@ async function terminateDaemonProcess(pid?: number): Promise { } } -async function findOpenAICompatProxyPort( +function listOpenAICompatProxyCandidatePorts( + preferredPort: number, + exact: boolean, excludedPorts: ReadonlySet = new Set() -): Promise { +): number[] { + if (exact) { + return excludedPorts.has(preferredPort) ? [] : [preferredPort]; + } + + const candidates = new Set(); + if ( + preferredPort >= OPENAI_COMPAT_PROXY_DEFAULT_PORT && + preferredPort <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10 && + !excludedPorts.has(preferredPort) + ) { + candidates.add(preferredPort); + } + for ( let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT; candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10; candidate += 1 ) { - if (excludedPorts.has(candidate)) { - continue; - } - if (!(await isPortOccupied(candidate))) { - return candidate; + if (!excludedPorts.has(candidate)) { + candidates.add(candidate); } } - return 0; + return [...candidates]; } -async function findOpenAICompatProxyPortNear( - preferredPort: number, - excludedPorts: ReadonlySet = new Set() -): Promise { - if (!excludedPorts.has(preferredPort) && !(await isPortOccupied(preferredPort))) { - return preferredPort; +function isPortBindConflictMessage(message?: string): boolean { + if (!message) { + return false; } - return findOpenAICompatProxyPort(excludedPorts); + const normalized = message.toLowerCase(); + return ( + message.includes('EADDRINUSE') || + normalized.includes('address already in use') || + (normalized.includes('is port') && normalized.includes('in use')) + ); } interface OpenAICompatProxyStateRecord { @@ -399,23 +396,6 @@ export async function startOpenAICompatProxy( if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) { return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` }; } - if (requiresExactPort && (await isPortOccupied(preferredPort))) { - return { - success: false, - port: preferredPort, - error: `Requested proxy port ${preferredPort} is already in use`, - }; - } - if (status.running) { - const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); - if (!stopped.success) { - return { - success: false, - port: preferredPort, - error: stopped.error || 'Failed to restart the running proxy', - }; - } - } const daemonEntry = await resolveDaemonEntrypoint(); if (!daemonEntry) { @@ -433,6 +413,7 @@ export async function startOpenAICompatProxy( new Promise((resolve) => { let resolved = false; let timeout: NodeJS.Timeout | null = null; + let stderr = ''; const authToken = generateProxyAuthToken(); const commitState = () => { if (proc.pid) { @@ -478,10 +459,14 @@ export async function startOpenAICompatProxy( ...(options.insecure ? ['--insecure'] : []), '--ccs-openai-proxy-daemon', ], - { stdio: 'ignore', detached: true } + { stdio: ['ignore', 'ignore', 'pipe'], detached: true } ); proc.unref(); + proc.stderr?.setEncoding('utf8'); + proc.stderr?.on('data', (chunk) => { + stderr += chunk; + }); if (persistState) { commitState(); } @@ -519,14 +504,21 @@ export async function startOpenAICompatProxy( timeout = setTimeout(poll, 1000); proc.on('error', (error) => { - finish({ success: false, port, error: error.message }); + finish({ + success: false, + port, + error: error.message, + bindConflict: isPortBindConflictMessage(error.message), + }); }); proc.on('exit', (code, signal) => { + const bindConflict = isPortBindConflictMessage(stderr); if (code === 0) { finish({ success: false, port, error: 'Proxy daemon exited before becoming healthy', + bindConflict, }); return; } @@ -534,7 +526,8 @@ export async function startOpenAICompatProxy( finish({ success: false, port, - error: `Proxy daemon exited with code ${code}`, + error: stderr.trim() || `Proxy daemon exited with code ${code}`, + bindConflict, }); return; } @@ -542,35 +535,42 @@ export async function startOpenAICompatProxy( success: false, port, error: `Proxy daemon was killed by signal ${signal}`, + bindConflict, }); }); }); const launchProxy = async (persistState: boolean): Promise => { - if (requiresExactPort) { - return launchOnPort(preferredPort, persistState); - } - const attemptedPorts = new Set(); let lastResult: OpenAICompatProxyLaunchResult | null = null; - for (let attempt = 0; attempt < 3; attempt += 1) { - const port = await findOpenAICompatProxyPortNear(preferredPort, attemptedPorts); - if (port === 0) { - return { - success: false, - port: preferredPort, - error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, - }; - } + const candidates = listOpenAICompatProxyCandidatePorts( + preferredPort, + requiresExactPort, + attemptedPorts + ); + if (candidates.length === 0) { + return { + success: false, + port: preferredPort, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + for (const port of candidates) { const result = await launchOnPort(port, persistState); if (result.success) { return result; } + if (requiresExactPort && result.bindConflict) { + return { + ...result, + error: `Requested proxy port ${preferredPort} is already in use`, + }; + } lastResult = result; attemptedPorts.add(port); - if (!(await isPortOccupied(port))) { + if (!result.bindConflict) { return result; } } @@ -579,7 +579,9 @@ export async function startOpenAICompatProxy( lastResult ?? { success: false, port: preferredPort, - error: 'Failed to start proxy', + error: requiresExactPort + ? `Requested proxy port ${preferredPort} is already in use` + : 'No free proxy port found in the proxy port range', } ); }; diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts index 01f9d7c0..886b68ea 100644 --- a/tests/unit/proxy/proxy-daemon-state.test.ts +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -34,4 +34,12 @@ describe('listOpenAICompatProxyProfileNames', () => { expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); }); + + it('skips malformed percent-encoded profile keys', () => { + fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true }); + fs.writeFileSync(path.join(path.dirname(getLegacyOpenAICompatProxySessionPath()), '%E0%A4%.session.json'), '{}\n', 'utf8'); + fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8'); + + expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); + }); }); From c0c119867abfce30a9ddb6b8b9a4365809c897a7 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:04:24 +0900 Subject: [PATCH 14/21] fix(proxy): persist daemon state after startup --- src/commands/proxy-command.ts | 2 +- src/proxy/proxy-daemon.ts | 6 +++--- tests/unit/commands/proxy-command.test.ts | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index c3dca91d..87fd8220 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -33,7 +33,7 @@ export function findPositionalArg( for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === '--') { - return args[i + 1]; + return i + 1 < args.length ? args[i + 1] : undefined; } if (arg.startsWith('-')) { if (optionsWithValues.includes(arg)) { diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 2a72f15d..8b5891dd 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -467,14 +467,14 @@ export async function startOpenAICompatProxy( proc.stderr?.on('data', (chunk) => { stderr += chunk; }); - if (persistState) { - commitState(); - } let attempts = 0; const poll = async () => { attempts += 1; if (await isOpenAICompatProxyRunning(port)) { + if (persistState) { + commitState(); + } finish({ success: true, pid: proc.pid, diff --git a/tests/unit/commands/proxy-command.test.ts b/tests/unit/commands/proxy-command.test.ts index ff71eb90..a1913dfc 100644 --- a/tests/unit/commands/proxy-command.test.ts +++ b/tests/unit/commands/proxy-command.test.ts @@ -9,4 +9,8 @@ describe('findPositionalArg', () => { it('treats arguments after -- as positional', () => { expect(findPositionalArg(['--', '--port', '3456'], ['--port'])).toBe('--port'); }); + + it('returns undefined when -- is the final argument', () => { + expect(findPositionalArg(['--'], ['--port'])).toBeUndefined(); + }); }); From 54c3b2d40b08f699715905b151d085d37c5af789 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:14:20 +0900 Subject: [PATCH 15/21] fix(proxy): avoid undefined status port output --- src/commands/proxy-command.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 87fd8220..391ad05e 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -136,7 +136,7 @@ async function handleStatus(args: string[] = []): Promise { const printStatus = (status: Awaited>) => { console.log( status.running - ? ok(`Proxy running on port ${status.port}`) + ? ok(`Proxy running on port ${status.port ?? 'unknown'}`) : info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`) ); if (status.host && status.port) { From 391bdddc2232836f9e49dcfa710c7ad1ffac19b9 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:25:16 +0900 Subject: [PATCH 16/21] fix(proxy): handle legacy stop and preferred ports --- src/proxy/proxy-daemon.ts | 118 ++++++++++++++++-- .../proxy/daemon-lifecycle.test.ts | 112 +++++++++++++++++ 2 files changed, 218 insertions(+), 12 deletions(-) diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 8b5891dd..c27904f0 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -115,11 +115,7 @@ function listOpenAICompatProxyCandidatePorts( } const candidates = new Set(); - if ( - preferredPort >= OPENAI_COMPAT_PROXY_DEFAULT_PORT && - preferredPort <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10 && - !excludedPorts.has(preferredPort) - ) { + if (!excludedPorts.has(preferredPort)) { candidates.add(preferredPort); } @@ -215,6 +211,22 @@ async function getOpenAICompatProxyStatusForProfile( }; } +async function getLegacyOpenAICompatProxyStatus(): Promise { + const session = readLegacyOpenAICompatProxySession(); + const pid = getLegacyOpenAICompatProxyPid(); + if (!session && !pid) { + return null; + } + + const port = session?.port; + const running = typeof port === 'number' ? await isOpenAICompatProxyRunning(port) : false; + return { + running, + pid: running ? pid || undefined : pid || undefined, + ...session, + }; +} + function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord { const session = readOpenAICompatProxySession(profileName); if (session) { @@ -243,10 +255,17 @@ export async function listOpenAICompatProxyStatuses(): Promise getOpenAICompatProxyStatusForProfile(profileName)) ); - return statuses.filter((status) => status.profileName); + const statuses = profileStatuses.filter((status) => status.profileName); + if (!legacySession?.profileName) { + const legacyStatus = await getLegacyOpenAICompatProxyStatus(); + if (legacyStatus) { + statuses.push(legacyStatus); + } + } + return statuses; } export async function getOpenAICompatProxyStatus( @@ -333,6 +352,74 @@ async function stopOpenAICompatProxyUnlocked( return { success: true }; } +async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{ + success: boolean; + error?: string; +}> { + const legacySession = readLegacyOpenAICompatProxySession(); + if (legacySession?.profileName) { + return stopOpenAICompatProxyUnlocked(legacySession.profileName); + } + + const pid = getLegacyOpenAICompatProxyPid(); + if (!pid) { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; + } + + const ownership = verifyProcessOwnership( + pid, + (commandLine) => + commandLine.includes('--ccs-openai-proxy-daemon') && + commandLine.includes('proxy-daemon-entry') + ); + + if (ownership === 'not-owned' || ownership === 'not-running') { + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + break; + } + } + + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already exited. + } + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ESRCH') { + return { success: false, error: `Failed to stop daemon: ${err.message}` }; + } + } + + removeLegacyOpenAICompatProxyPid(); + removeLegacyOpenAICompatProxySession(); + return { success: true }; +} + function removeOpenAICompatProxyState( state: OpenAICompatProxyStateRecord, profileName: string @@ -356,15 +443,22 @@ export async function stopOpenAICompatProxy( } const statuses = await listOpenAICompatProxyStatuses(); + const failures: string[] = []; for (const status of statuses) { - if (!status.profileName) { - continue; - } - const stopped = await stopOpenAICompatProxyUnlocked(status.profileName); + const stopped = status.profileName + ? await stopOpenAICompatProxyUnlocked(status.profileName) + : await stopLegacyOpenAICompatProxyUnlocked(); if (!stopped.success) { - return stopped; + failures.push( + status.profileName + ? `${status.profileName}: ${stopped.error || 'failed to stop proxy'}` + : `legacy proxy: ${stopped.error || 'failed to stop proxy'}` + ); } } + if (failures.length > 0) { + return { success: false, error: `Failed to stop some proxies: ${failures.join('; ')}` }; + } return { success: true }; }); } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 3900f8d7..0be5da3d 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import getPort from 'get-port'; import { getOpenAICompatProxyStatus, + listOpenAICompatProxyStatuses, startOpenAICompatProxy, stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; @@ -12,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou import { getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxySessionPath, + getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; @@ -352,4 +354,114 @@ describe('openai proxy daemon lifecycle', () => { busyServer.stop(true); } }); + + it('reuses the last-known port even when it is outside the default fallback range', async () => { + const preferredPort = await getPort(); + const settingsPath = path.join(tempDir, 'outside-range.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an outside-range OpenAI-compatible profile'); + } + + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true }); + fs.writeFileSync( + getOpenAICompatProxySessionPath('outside-range'), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: preferredPort, + baseUrl: profile.baseUrl, + authToken: 'stale-token', + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).toBe(preferredPort); + }); + + it('stops legacy daemons even when the legacy session is missing a profile name', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'legacy-missing-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy-missing-profile', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected a legacy fallback OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.pid).toBeDefined(); + + const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath()); + fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8'); + fs.writeFileSync( + getLegacyOpenAICompatProxySessionPath(), + JSON.stringify( + { + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port, + baseUrl: profile.baseUrl, + authToken: started.authToken, + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.daemon.pid'), { force: true }); + fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.session.json'), { force: true }); + + const statuses = await listOpenAICompatProxyStatuses(); + expect(statuses.some((status) => status.port === port)).toBe(true); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false); + expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false); + }, 35000); }); From afcb1abf9e507155f0978898ac5b9d565ec3af88 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:30:51 +0900 Subject: [PATCH 17/21] fix(proxy): retain explicit profile names in status --- src/proxy/proxy-daemon.ts | 2 +- tests/integration/proxy/daemon-lifecycle.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index c27904f0..c70715fb 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -199,7 +199,7 @@ async function getOpenAICompatProxyStatusForProfile( const state = getOpenAICompatProxyStateForProfile(profileName); const session = state.session; if (!session) { - return { running: false }; + return { running: false, profileName }; } const port = session.port; diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 0be5da3d..200de4fe 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -464,4 +464,10 @@ describe('openai proxy daemon lifecycle', () => { expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false); expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false); }, 35000); + + it('keeps the requested profile name in stopped status results', async () => { + const status = await getOpenAICompatProxyStatus('never-started'); + expect(status.running).toBe(false); + expect(status.profileName).toBe('never-started'); + }); }); From 6928d8bb65cb51ab6b1e1b7095989931c040da80 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 15:44:20 +0900 Subject: [PATCH 18/21] fix(proxy): parse flag options before profile args --- src/commands/proxy-command.ts | 8 ++++++-- tests/unit/commands/proxy-command.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 391ad05e..119cc90e 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -28,7 +28,8 @@ function hasHelpFlag(args: string[]): boolean { export function findPositionalArg( args: string[], - optionsWithValues: string[] = [] + optionsWithValues: string[] = [], + flagOptions: string[] = [] ): string | undefined { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; @@ -36,6 +37,9 @@ export function findPositionalArg( return i + 1 < args.length ? args[i + 1] : undefined; } if (arg.startsWith('-')) { + if (flagOptions.includes(arg)) { + continue; + } if (optionsWithValues.includes(arg)) { i += 1; } @@ -90,7 +94,7 @@ async function handleStart(args: string[]): Promise { if (hasHelpFlag(args)) { return showHelp(); } - const profileName = findPositionalArg(args, ['--port', '--host']); + const profileName = findPositionalArg(args, ['--port', '--host'], ['--insecure']); if (!profileName) { console.error( fail('Usage: ccs proxy start [--port ] [--host ] [--insecure]') diff --git a/tests/unit/commands/proxy-command.test.ts b/tests/unit/commands/proxy-command.test.ts index a1913dfc..a3db75b1 100644 --- a/tests/unit/commands/proxy-command.test.ts +++ b/tests/unit/commands/proxy-command.test.ts @@ -6,6 +6,12 @@ describe('findPositionalArg', () => { expect(findPositionalArg(['--port', '3456', 'ccg'], ['--port'])).toBe('ccg'); }); + it('skips flag options that do not take values', () => { + expect(findPositionalArg(['--insecure', 'ccg'], ['--port', '--host'], ['--insecure'])).toBe( + 'ccg' + ); + }); + it('treats arguments after -- as positional', () => { expect(findPositionalArg(['--', '--port', '3456'], ['--port'])).toBe('--port'); }); From 1cfed73ca5bb8d1e8def81aeb63ebe36a074badd Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 20 Apr 2026 12:44:30 -0400 Subject: [PATCH 19/21] fix(proxy): harden stale daemon ownership checks --- src/proxy/proxy-daemon-state.ts | 35 ++-- src/proxy/proxy-daemon.ts | 70 +++++-- .../proxy/daemon-lifecycle.test.ts | 178 ++++++++++++++++++ tests/unit/proxy/proxy-daemon-state.test.ts | 8 + 4 files changed, 258 insertions(+), 33 deletions(-) diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index d5610e69..8ca7bc05 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -103,36 +103,43 @@ export function removeLegacyOpenAICompatProxySession(): void { } } -export function listOpenAICompatProxyProfileNames(): string[] { +function decodeProfileKey(profileKey: string): string[] { + try { + return [decodeURIComponent(profileKey)]; + } catch { + return []; + } +} + +function listProfileNamesForSuffix(suffix: string, legacyName?: string): string[] { try { const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); const profileKeys = new Set(); for (const entry of entries) { - if ( - !entry.isFile() || - entry.name === 'session.json' || - !entry.name.endsWith('.session.json') - ) { + if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) { continue; } - const profileKey = entry.name.slice(0, -'.session.json'.length); + const profileKey = entry.name.slice(0, -suffix.length); if (!profileKey) { continue; } profileKeys.add(profileKey); } - return [...profileKeys].flatMap((profileKey) => { - try { - return [decodeURIComponent(profileKey)]; - } catch { - return []; - } - }); + return [...profileKeys].flatMap(decodeProfileKey); } catch { return []; } } +export function listOpenAICompatProxyProfileNames(): string[] { + return [ + ...new Set([ + ...listProfileNamesForSuffix('.session.json', 'session.json'), + ...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'), + ]), + ]; +} + export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index c70715fb..33ee9b92 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -47,6 +47,31 @@ interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult { bindConflict?: boolean; } +interface OpenAICompatProxyHealthPayload { + service?: string; + profile?: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function verifyProxyProcessOwnership( + pid: number, + profileName?: string +): ReturnType { + const profilePattern = profileName + ? new RegExp(`(^|\\s)--profile(?:\\s+|=)${escapeRegExp(profileName)}(?=\\s|$)`) + : null; + return verifyProcessOwnership( + pid, + (commandLine) => + commandLine.includes('--ccs-openai-proxy-daemon') && + commandLine.includes('proxy-daemon-entry') && + (!profilePattern || profilePattern.test(commandLine)) + ); +} + function generateProxyAuthToken(): string { return crypto.randomBytes(24).toString('hex'); } @@ -162,7 +187,10 @@ async function resolveDaemonEntrypoint(): Promise { return null; } -export async function isOpenAICompatProxyRunning(port: number): Promise { +export async function isOpenAICompatProxyRunning( + port: number, + expectedProfileName?: string +): Promise { return new Promise((resolve) => { const req = http.request( { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 }, @@ -176,8 +204,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise return; } try { - const payload = JSON.parse(body) as { service?: string }; - resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME); + const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload; + resolve( + payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME && + (!expectedProfileName || payload.profile === expectedProfileName) + ); } catch { resolve(false); } @@ -199,11 +230,11 @@ async function getOpenAICompatProxyStatusForProfile( const state = getOpenAICompatProxyStateForProfile(profileName); const session = state.session; if (!session) { - return { running: false, profileName }; + return { running: false, profileName, pid: state.pid || undefined }; } const port = session.port; - const running = await isOpenAICompatProxyRunning(port); + const running = await isOpenAICompatProxyRunning(port, profileName); return { running, pid: running ? state.pid || undefined : undefined, @@ -219,7 +250,8 @@ async function getLegacyOpenAICompatProxyStatus(): Promise - commandLine.includes('--ccs-openai-proxy-daemon') && - commandLine.includes('proxy-daemon-entry') - ); + const ownership = verifyProxyProcessOwnership(pid, profileName); if (ownership === 'not-owned') { removeOpenAICompatProxyState(state, profileName); @@ -368,12 +395,7 @@ async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{ return { success: true }; } - const ownership = verifyProcessOwnership( - pid, - (commandLine) => - commandLine.includes('--ccs-openai-proxy-daemon') && - commandLine.includes('proxy-daemon-entry') - ); + const ownership = verifyProxyProcessOwnership(pid); if (ownership === 'not-owned' || ownership === 'not-running') { removeLegacyOpenAICompatProxyPid(); @@ -490,6 +512,16 @@ export async function startOpenAICompatProxy( if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) { return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` }; } + if (status.pid && !status.port) { + const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); + if (!stopped.success) { + return { + success: false, + port: preferredPort, + error: stopped.error || 'Failed to clear stale proxy state', + }; + } + } const daemonEntry = await resolveDaemonEntrypoint(); if (!daemonEntry) { @@ -565,7 +597,7 @@ export async function startOpenAICompatProxy( let attempts = 0; const poll = async () => { attempts += 1; - if (await isOpenAICompatProxyRunning(port)) { + if (await isOpenAICompatProxyRunning(port, profile.profileName)) { if (persistState) { commitState(); } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 200de4fe..4721f9a4 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -13,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou import { getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxySessionPath, + getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; @@ -470,4 +471,181 @@ describe('openai proxy daemon lifecycle', () => { expect(status.running).toBe(false); expect(status.profileName).toBe('never-started'); }); + + it('does not treat another profile on the same port as already running', async () => { + const sharedPort = await getPort(); + const firstSettingsPath = path.join(tempDir, 'profile-b.settings.json'); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-b', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const firstProfile = resolveOpenAICompatProfileConfig('profile-b', firstSettingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-b', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!firstProfile) { + throw new Error('Expected first OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(firstProfile, { port: sharedPort }); + expect(firstStart.success).toBe(true); + + const secondSettingsPath = path.join(tempDir, 'profile-a.settings.json'); + fs.writeFileSync( + secondSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-a', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const secondProfile = resolveOpenAICompatProfileConfig('profile-a', secondSettingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-a', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!secondProfile) { + throw new Error('Expected second OpenAI-compatible profile'); + } + + fs.writeFileSync( + getOpenAICompatProxySessionPath('profile-a'), + JSON.stringify( + { + profileName: 'profile-a', + settingsPath: secondProfile.settingsPath, + host: '127.0.0.1', + port: sharedPort, + baseUrl: secondProfile.baseUrl, + authToken: 'stale-token-a', + model: secondProfile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const secondStart = await startOpenAICompatProxy(secondProfile); + expect(secondStart.success).toBe(true); + expect(secondStart.alreadyRunning).not.toBe(true); + expect(secondStart.authToken).not.toBe('stale-token-a'); + }); + + it('replaces pid-only proxy state before starting a new daemon', async () => { + const firstPort = await getPort(); + const replacementPort = await getPort(); + const settingsPath = path.join(tempDir, 'pid-only.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-pid-only', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('pid-only', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-pid-only', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected pid-only OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(profile, { port: firstPort }); + expect(firstStart.success).toBe(true); + expect(firstStart.pid).toBeDefined(); + + fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only')); + + const replacement = await startOpenAICompatProxy(profile, { port: replacementPort }); + expect(replacement.success).toBe(true); + expect(replacement.port).toBe(replacementPort); + expect(replacement.pid).toBeDefined(); + expect(replacement.pid).not.toBe(firstStart.pid); + + const stalePidPath = getOpenAICompatProxyPidPath('pid-only'); + expect(fs.readFileSync(stalePidPath, 'utf8').trim()).toBe(String(replacement.pid)); + expect((await fetch(`http://127.0.0.1:${replacementPort}/health`)).status).toBe(200); + }); + + it('does not stop another profile when a stale pid file points at its daemon', async () => { + const firstPort = await getPort(); + const secondPort = await getPort(); + const firstSettingsPath = path.join(tempDir, 'profile-b-stale-pid.settings.json'); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + const firstProfile = resolveOpenAICompatProfileConfig('profile-b-stale-pid', firstSettingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!firstProfile) { + throw new Error('Expected first OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort }); + expect(firstStart.success).toBe(true); + expect(firstStart.pid).toBeDefined(); + + const secondSettingsPath = path.join(tempDir, 'profile-a-stale-pid.settings.json'); + fs.writeFileSync( + secondSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + const secondProfile = resolveOpenAICompatProfileConfig('profile-a-stale-pid', secondSettingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!secondProfile) { + throw new Error('Expected second OpenAI-compatible profile'); + } + + fs.writeFileSync( + getOpenAICompatProxyPidPath('profile-a-stale-pid'), + String(firstStart.pid), + 'utf8' + ); + + const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort }); + expect(secondStart.success).toBe(true); + expect(secondStart.port).toBe(secondPort); + expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200); + expect((await fetch(`http://127.0.0.1:${secondPort}/health`)).status).toBe(200); + }); }); diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts index 886b68ea..c653b030 100644 --- a/tests/unit/proxy/proxy-daemon-state.test.ts +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -4,6 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { getLegacyOpenAICompatProxySessionPath, + getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state'; @@ -42,4 +43,11 @@ describe('listOpenAICompatProxyProfileNames', () => { expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); }); + + it('includes pid-only profile state in the discovered profile list', () => { + fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true }); + fs.writeFileSync(getOpenAICompatProxyPidPath('ccg'), '123\n', 'utf8'); + + expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); + }); }); From 69da284104dbd5ab0c367ac08fcafeb1cd079770 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 20 Apr 2026 12:44:53 -0400 Subject: [PATCH 20/21] test(proxy): gate proxy e2e coverage in checks --- .github/workflows/ci.yml | 5 +++++ .github/workflows/dev-release.yml | 5 +++++ .github/workflows/release.yml | 5 +++++ tests/e2e/proxy-command.e2e.test.ts | 4 ++++ 4 files changed, 19 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7103da86..86529a56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,3 +139,8 @@ jobs: - name: Test run: bun run test:all + + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index cf9f59d7..50de0621 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -49,6 +49,11 @@ jobs: - name: Validate (typecheck + lint + format + tests) run: bun run validate + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e + - name: Release id: release env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adb7a3e1..466394ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,11 @@ jobs: - name: Validate (typecheck + lint + format + tests) run: bun run validate + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e + - name: Release id: release env: diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index ff86ad5a..0e10c90b 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -22,6 +22,10 @@ function runCli(args: string[], extraEnv: Record = {}) { } beforeAll(() => { + if (process.env.CCS_E2E_SKIP_BUILD === '1') { + expect(fs.existsSync(DIST_ENTRY)).toBe(true); + return; + } const result = spawnSync(process.execPath, ['run', 'build'], { encoding: 'utf8', env: process.env, From 8b2711062eb2076858d414f29af01d046642fa35 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 20 Apr 2026 12:45:10 -0400 Subject: [PATCH 21/21] docs(proxy): document multi-profile proxy lifecycle --- docs/openai-compatible-providers.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index 4478e58d..7b4e1b43 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -72,7 +72,10 @@ Useful variants: ```bash ccs proxy start hf --host 127.0.0.1 +ccs proxy activate hf ccs proxy activate --fish +ccs proxy status hf +ccs proxy stop hf ``` `ccs proxy activate` now prints the full local runtime contract: @@ -85,16 +88,26 @@ ccs proxy activate --fish - `API_TIMEOUT_MS` - `NO_PROXY` -## One Active Proxy Profile +## Multiple Active Proxy Profiles -The current runtime is a single local proxy daemon. +CCS now stores OpenAI-compatible proxy state per profile instead of treating the +runtime as a singleton. -- Reusing the same OpenAI-compatible profile is supported -- Starting a different OpenAI-compatible profile while one proxy is already - running is rejected instead of silently replacing the active upstream +- Different compatible profiles can run at the same time on separate local ports +- `ccs proxy activate` without a profile stays convenient when only one proxy is + running +- When multiple proxies are running, pass the profile explicitly to + `activate`, `status`, or `stop` -This is intentional to avoid breaking an in-flight Claude session by swapping -its upstream provider out from under it. +If you want deterministic ports, configure them in `~/.ccs/config.yaml`: + +```yaml +proxy: + port: 3456 + profile_ports: + hf: 3456 + openai: 3461 +``` ## Request-Time Routing