fix(proxy): harden stale daemon ownership checks

This commit is contained in:
Tam Nhu Tran
2026-04-20 12:44:30 -04:00
parent 6928d8bb65
commit 1cfed73ca5
4 changed files with 258 additions and 33 deletions
+21 -14
View File
@@ -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 { try {
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
const profileKeys = new Set<string>(); const profileKeys = new Set<string>();
for (const entry of entries) { for (const entry of entries) {
if ( if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) {
!entry.isFile() ||
entry.name === 'session.json' ||
!entry.name.endsWith('.session.json')
) {
continue; continue;
} }
const profileKey = entry.name.slice(0, -'.session.json'.length); const profileKey = entry.name.slice(0, -suffix.length);
if (!profileKey) { if (!profileKey) {
continue; continue;
} }
profileKeys.add(profileKey); profileKeys.add(profileKey);
} }
return [...profileKeys].flatMap((profileKey) => { return [...profileKeys].flatMap(decodeProfileKey);
try {
return [decodeURIComponent(profileKey)];
} catch {
return [];
}
});
} catch { } catch {
return []; return [];
} }
} }
export function listOpenAICompatProxyProfileNames(): string[] {
return [
...new Set([
...listProfileNamesForSuffix('.session.json', 'session.json'),
...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'),
]),
];
}
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
+51 -19
View File
@@ -47,6 +47,31 @@ interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
bindConflict?: boolean; bindConflict?: boolean;
} }
interface OpenAICompatProxyHealthPayload {
service?: string;
profile?: string;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function verifyProxyProcessOwnership(
pid: number,
profileName?: string
): ReturnType<typeof verifyProcessOwnership> {
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 { function generateProxyAuthToken(): string {
return crypto.randomBytes(24).toString('hex'); return crypto.randomBytes(24).toString('hex');
} }
@@ -162,7 +187,10 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
return null; return null;
} }
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> { export async function isOpenAICompatProxyRunning(
port: number,
expectedProfileName?: string
): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const req = http.request( const req = http.request(
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 }, { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
@@ -176,8 +204,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
return; return;
} }
try { try {
const payload = JSON.parse(body) as { service?: string }; const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload;
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME); resolve(
payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME &&
(!expectedProfileName || payload.profile === expectedProfileName)
);
} catch { } catch {
resolve(false); resolve(false);
} }
@@ -199,11 +230,11 @@ async function getOpenAICompatProxyStatusForProfile(
const state = getOpenAICompatProxyStateForProfile(profileName); const state = getOpenAICompatProxyStateForProfile(profileName);
const session = state.session; const session = state.session;
if (!session) { if (!session) {
return { running: false, profileName }; return { running: false, profileName, pid: state.pid || undefined };
} }
const port = session.port; const port = session.port;
const running = await isOpenAICompatProxyRunning(port); const running = await isOpenAICompatProxyRunning(port, profileName);
return { return {
running, running,
pid: running ? state.pid || undefined : undefined, pid: running ? state.pid || undefined : undefined,
@@ -219,7 +250,8 @@ async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStat
} }
const port = session?.port; const port = session?.port;
const running = typeof port === 'number' ? await isOpenAICompatProxyRunning(port) : false; const running =
typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false;
return { return {
running, running,
pid: running ? pid || undefined : pid || undefined, pid: running ? pid || undefined : pid || undefined,
@@ -297,12 +329,7 @@ async function stopOpenAICompatProxyUnlocked(
return { success: true }; return { success: true };
} }
const ownership = verifyProcessOwnership( const ownership = verifyProxyProcessOwnership(pid, profileName);
pid,
(commandLine) =>
commandLine.includes('--ccs-openai-proxy-daemon') &&
commandLine.includes('proxy-daemon-entry')
);
if (ownership === 'not-owned') { if (ownership === 'not-owned') {
removeOpenAICompatProxyState(state, profileName); removeOpenAICompatProxyState(state, profileName);
@@ -368,12 +395,7 @@ async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{
return { success: true }; return { success: true };
} }
const ownership = verifyProcessOwnership( const ownership = verifyProxyProcessOwnership(pid);
pid,
(commandLine) =>
commandLine.includes('--ccs-openai-proxy-daemon') &&
commandLine.includes('proxy-daemon-entry')
);
if (ownership === 'not-owned' || ownership === 'not-running') { if (ownership === 'not-owned' || ownership === 'not-running') {
removeLegacyOpenAICompatProxyPid(); removeLegacyOpenAICompatProxyPid();
@@ -490,6 +512,16 @@ export async function startOpenAICompatProxy(
if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) { if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` }; 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(); const daemonEntry = await resolveDaemonEntrypoint();
if (!daemonEntry) { if (!daemonEntry) {
@@ -565,7 +597,7 @@ export async function startOpenAICompatProxy(
let attempts = 0; let attempts = 0;
const poll = async () => { const poll = async () => {
attempts += 1; attempts += 1;
if (await isOpenAICompatProxyRunning(port)) { if (await isOpenAICompatProxyRunning(port, profile.profileName)) {
if (persistState) { if (persistState) {
commitState(); commitState();
} }
@@ -13,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou
import { import {
getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxyPidPath,
getLegacyOpenAICompatProxySessionPath, getLegacyOpenAICompatProxySessionPath,
getOpenAICompatProxyPidPath,
getOpenAICompatProxySessionPath, getOpenAICompatProxySessionPath,
} from '../../../src/proxy/proxy-daemon-paths'; } from '../../../src/proxy/proxy-daemon-paths';
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
@@ -470,4 +471,181 @@ describe('openai proxy daemon lifecycle', () => {
expect(status.running).toBe(false); expect(status.running).toBe(false);
expect(status.profileName).toBe('never-started'); 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);
});
}); });
@@ -4,6 +4,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { import {
getLegacyOpenAICompatProxySessionPath, getLegacyOpenAICompatProxySessionPath,
getOpenAICompatProxyPidPath,
getOpenAICompatProxySessionPath, getOpenAICompatProxySessionPath,
} from '../../../src/proxy/proxy-daemon-paths'; } from '../../../src/proxy/proxy-daemon-paths';
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state'; import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
@@ -42,4 +43,11 @@ describe('listOpenAICompatProxyProfileNames', () => {
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); 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']);
});
}); });