From fa31bea6722afa35e0fcce3f20522c0586b6313e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 08:05:45 -0400 Subject: [PATCH] fix(proxy): require owned daemon PID before reusing legacy/profile proxy session Squash merge PR #1298 into dev. --- src/proxy/proxy-daemon.ts | 20 +- .../proxy/daemon-lifecycle.test.ts | 185 +++++++++++++++++- 2 files changed, 192 insertions(+), 13 deletions(-) diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 22dac996..999376d5 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -226,11 +226,16 @@ async function getOpenAICompatProxyStatusForProfile( return { running: false, profileName, pid: state.pid || undefined }; } + const pid = state.pid; + if (!pid || verifyProxyProcessOwnership(pid, profileName) !== 'owned') { + return { running: false, profileName, port: session.port }; + } + const port = session.port; const running = await isOpenAICompatProxyRunning(port, profileName); return { running, - pid: running ? state.pid || undefined : undefined, + pid: running ? pid : undefined, ...session, }; } @@ -242,12 +247,21 @@ async function getLegacyOpenAICompatProxyStatus(): Promise 0 && + verifyProxyProcessOwnership(pid, profileName) === 'owned'; const port = session?.port; const running = - typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false; + hasOwnedPid && typeof port === 'number' + ? await isOpenAICompatProxyRunning(port, session?.profileName) + : false; return { running, - pid: running ? pid || undefined : pid || undefined, + port, + pid: running ? pid || undefined : undefined, ...session, }; } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index c19d09bb..0efcad03 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -13,6 +13,7 @@ import { import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; import { + OPENAI_COMPAT_PROXY_SERVICE_NAME, OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, getLegacyOpenAICompatProxyPidPath, @@ -93,6 +94,24 @@ function readProcessCommandLine(pid: number): string | null { } } +function startSpoofedOpenAICompatHealthServer(port: number, profileName: string) { + return Bun.serve({ + hostname: '127.0.0.1', + port, + fetch(request) { + const { pathname } = new URL(request.url); + if (pathname === '/health') { + return Response.json({ + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + profile: profileName, + }); + } + + return new Response('not found', { status: 404 }); + }, + }); +} + describe('openai proxy daemon lifecycle', () => { it('starts, reports status, serves health/models, and stops', async () => { const port = await getPort(); @@ -574,6 +593,144 @@ describe('openai proxy daemon lifecycle', () => { expect(started.port).not.toBe(stalePort); }); + it('does not reuse a profile session when its recorded pid is not owned by the proxy daemon', async () => { + const profileName = await findProfileNameWithFreeAdaptivePort('unowned-profile-session'); + const preferredPort = resolveOpenAICompatProxyPreferredPort(profileName); + const stalePort = await getPortOutsideOpenAICompatAdaptiveRange(); + const settingsPath = path.join(tempDir, `${profileName}.settings.json`); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-unowned-profile-session', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-unowned-profile-session', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an unowned-profile-session OpenAI-compatible profile'); + } + + const attackerServer = startSpoofedOpenAICompatHealthServer(stalePort, profileName); + + try { + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath(profileName)), { recursive: true }); + fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(process.pid), 'utf8'); + fs.writeFileSync( + getOpenAICompatProxySessionPath(profileName), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: stalePort, + baseUrl: profile.baseUrl, + authToken: 'stale-token', + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const status = await getOpenAICompatProxyStatus(profileName); + expect(status.running).toBe(false); + expect(status.port).toBe(stalePort); + expect(status.pid).toBeUndefined(); + expect(status.authToken).toBeUndefined(); + expect(status.baseUrl).toBeUndefined(); + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.alreadyRunning).not.toBe(true); + expect(started.port).toBe(preferredPort); + expect(started.port).not.toBe(stalePort); + expect(started.authToken).not.toBe('stale-token'); + } finally { + attackerServer.stop(true); + } + }); + + it('does not reuse a legacy session when its recorded pid is not owned by the proxy daemon', async () => { + const profileName = await findProfileNameWithFreeAdaptivePort('unowned-legacy-session'); + const preferredPort = resolveOpenAICompatProxyPreferredPort(profileName); + const stalePort = await getPortOutsideOpenAICompatAdaptiveRange(); + const settingsPath = path.join(tempDir, `${profileName}.settings.json`); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-unowned-legacy-session', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-unowned-legacy-session', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an unowned-legacy-session OpenAI-compatible profile'); + } + + const attackerServer = startSpoofedOpenAICompatHealthServer(stalePort, profileName); + + try { + fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true }); + fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(process.pid), 'utf8'); + fs.writeFileSync( + getLegacyOpenAICompatProxySessionPath(), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: stalePort, + baseUrl: profile.baseUrl, + authToken: 'stale-token', + model: profile.model, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const status = await getOpenAICompatProxyStatus(profileName); + expect(status.running).toBe(false); + expect(status.port).toBe(stalePort); + expect(status.pid).toBeUndefined(); + expect(status.authToken).toBeUndefined(); + expect(status.baseUrl).toBeUndefined(); + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.alreadyRunning).not.toBe(true); + expect(started.port).toBe(preferredPort); + expect(started.port).not.toBe(stalePort); + expect(started.authToken).not.toBe('stale-token'); + } finally { + attackerServer.stop(true); + } + }); + it('does not keep a stopped shared-default profile anchored to legacy port 3456', async () => { const settingsPath = path.join(tempDir, 'legacy-shared-default.settings.json'); fs.writeFileSync( @@ -819,11 +976,15 @@ describe('openai proxy daemon lifecycle', () => { }), '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', - }); + 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'); } @@ -844,11 +1005,15 @@ describe('openai proxy daemon lifecycle', () => { }), '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', - }); + 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'); }