From 1cfed73ca5bb8d1e8def81aeb63ebe36a074badd Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 20 Apr 2026 12:44:30 -0400 Subject: [PATCH] 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']); + }); });