diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index 8f3b7e24..abf06ce2 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import { resolveOpenAICompatProfileConfig } from './profile-router'; import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; import { startOpenAICompatProxyServer } from './server/proxy-server'; @@ -9,6 +10,7 @@ interface RuntimeOptions { profileName: string; settingsPath: string; authToken: string; + authTokenFile: string; insecure: boolean; } @@ -18,6 +20,7 @@ function parseArgs(argv: string[]): RuntimeOptions { let profileName = ''; let settingsPath = ''; let authToken = ''; + let authTokenFile = ''; let insecure = false; for (let i = 0; i < argv.length; i++) { @@ -42,16 +45,36 @@ function parseArgs(argv: string[]): RuntimeOptions { authToken = argv[++i] || ''; continue; } + if (arg === '--auth-token-file' && argv[i + 1]) { + authTokenFile = argv[++i] || ''; + continue; + } if (arg === '--insecure') { insecure = true; } } - return { port, host, profileName, settingsPath, authToken, insecure }; + return { port, host, profileName, settingsPath, authToken, authTokenFile, insecure }; +} + +function readAuthToken(options: RuntimeOptions): string { + if (options.authTokenFile) { + try { + const token = fs.readFileSync(options.authTokenFile, 'utf8').trim(); + fs.unlinkSync(options.authTokenFile); + return token; + } catch (error) { + throw new Error(`Failed to read local proxy auth token file: ${(error as Error).message}`); + } + } + + return options.authToken; } function startRuntime(options: RuntimeOptions): void { - if (!options.authToken.trim()) { + const authToken = readAuthToken(options); + + if (!authToken.trim()) { throw new Error('Missing local proxy auth token'); } @@ -71,7 +94,7 @@ function startRuntime(options: RuntimeOptions): void { profile, host: options.host, port: options.port, - authToken: options.authToken, + authToken, insecure: options.insecure, }); server.once('error', (error) => { diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 8ca7bc05..0ed9b046 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -20,7 +21,40 @@ export interface OpenAICompatProxySession { } function ensureProxyDir(): void { - fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); + const proxyDir = getOpenAICompatProxyDir(); + fs.mkdirSync(proxyDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(proxyDir, 0o700); +} + +function encodeProfileFileKey(profileName: string): string { + return encodeURIComponent(profileName); +} + +export function writeOpenAICompatProxyAuthTokenFile( + profileName: string, + authToken: string +): string { + ensureProxyDir(); + const tokenPath = path.join( + getOpenAICompatProxyDir(), + `.${encodeProfileFileKey(profileName)}.${crypto.randomBytes(8).toString('hex')}.token` + ); + const fd = fs.openSync(tokenPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, authToken, 'utf8'); + } finally { + fs.closeSync(fd); + } + fs.chmodSync(tokenPath, 0o600); + return tokenPath; +} + +export function removeOpenAICompatProxyAuthTokenFile(tokenPath: string): void { + try { + fs.unlinkSync(tokenPath); + } catch { + // Best-effort cleanup. + } } function readPid(pidPath: string): number | null { @@ -43,7 +77,11 @@ export function getLegacyOpenAICompatProxyPid(): number | null { export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); - fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); + fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(getOpenAICompatProxyPidPath(profileName), 0o600); } export function removeOpenAICompatProxyPid(profileName: string): void { @@ -80,11 +118,12 @@ export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); - fs.writeFileSync( - getOpenAICompatProxySessionPath(session.profileName), - JSON.stringify(session, null, 2) + '\n', - 'utf8' - ); + const sessionPath = getOpenAICompatProxySessionPath(session.profileName); + fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(sessionPath, 0o600); } export function removeOpenAICompatProxySession(profileName: string): void { diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index a96caf85..22dac996 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -19,10 +19,12 @@ import { readOpenAICompatProxySession, removeLegacyOpenAICompatProxyPid, removeLegacyOpenAICompatProxySession, + removeOpenAICompatProxyAuthTokenFile, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, type OpenAICompatProxySession, + writeOpenAICompatProxyAuthTokenFile, writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; @@ -82,7 +84,8 @@ function generateProxyAuthToken(): string { async function withOpenAICompatProxyLock(operation: () => Promise): Promise { const proxyDir = getOpenAICompatProxyDir(); - await fs.promises.mkdir(proxyDir, { recursive: true }); + await fs.promises.mkdir(proxyDir, { recursive: true, mode: 0o700 }); + await fs.promises.chmod(proxyDir, 0o700); let release: (() => Promise) | undefined; try { @@ -543,6 +546,7 @@ export async function startOpenAICompatProxy( let timeout: NodeJS.Timeout | null = null; let stderr = ''; const authToken = generateProxyAuthToken(); + const authTokenFile = writeOpenAICompatProxyAuthTokenFile(profile.profileName, authToken); const commitState = () => { if (proc.pid) { writeOpenAICompatProxyPid(profile.profileName, proc.pid); @@ -563,9 +567,12 @@ export async function startOpenAICompatProxy( if (resolved) return; resolved = true; if (timeout) clearTimeout(timeout); - if (!result.success && persistState) { - removeOpenAICompatProxyPid(profile.profileName); - removeOpenAICompatProxySession(profile.profileName); + if (!result.success) { + removeOpenAICompatProxyAuthTokenFile(authTokenFile); + if (persistState) { + removeOpenAICompatProxyPid(profile.profileName); + removeOpenAICompatProxySession(profile.profileName); + } } resolve(result); }; @@ -582,8 +589,8 @@ export async function startOpenAICompatProxy( profile.profileName, '--settings-path', profile.settingsPath, - '--auth-token', - authToken, + '--auth-token-file', + authTokenFile, ...(options.insecure ? ['--insecure'] : []), '--ccs-openai-proxy-daemon', ], diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 6fc5e15f..c19d09bb 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -71,6 +72,27 @@ async function getPortOutsideOpenAICompatAdaptiveRange(): Promise { throw new Error('No stale proxy fixture port found outside the adaptive range'); } +function readProcessCommandLine(pid: number): string | null { + if (process.platform === 'linux') { + try { + const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + return commandLine.split('\u0000').filter(Boolean).join(' ') || null; + } catch { + // Fall back to ps below for platforms without readable procfs entries. + } + } + + try { + return ( + execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + }).trim() || null + ); + } catch { + return null; + } +} + describe('openai proxy daemon lifecycle', () => { it('starts, reports status, serves health/models, and stops', async () => { const port = await getPort(); @@ -101,18 +123,42 @@ describe('openai proxy daemon lifecycle', () => { const started = await startOpenAICompatProxy(profile, { port }); expect(started.success).toBe(true); expect(started.authToken).toBeTruthy(); + const authToken = started.authToken; + if (!authToken) { + throw new Error('Expected proxy auth token'); + } const status = await getOpenAICompatProxyStatus(); expect(status.running).toBe(true); expect(status.profileName).toBe('hf'); - expect(status.authToken).toBe(started.authToken); + expect(status.authToken).toBe(authToken); + + const sessionPath = getOpenAICompatProxySessionPath('hf'); + const proxyDir = path.dirname(sessionPath); + if (process.platform !== 'win32') { + expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(sessionPath).mode & 0o777).toBe(0o600); + } + expect(fs.readdirSync(proxyDir).some((entry) => entry.endsWith('.token'))).toBe(false); + if (started.pid) { + const commandLine = readProcessCommandLine(started.pid); + if (!commandLine) { + console.warn( + `Skipping daemon argv assertion: could not read command line for PID ${started.pid}` + ); + } else { + expect(commandLine).toContain('--auth-token-file'); + expect(commandLine).not.toContain(authToken); + expect(commandLine).not.toMatch(/(^|\s)--auth-token(?:\s|=|$)/); + } + } const health = await fetch(`http://127.0.0.1:${port}/health`); expect(health.status).toBe(200); const models = (await ( await fetch(`http://127.0.0.1:${port}/v1/models`, { - headers: { 'x-api-key': started.authToken! }, + headers: { 'x-api-key': authToken }, }) ).json()) as { data?: Array<{ id: string }> }; expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); diff --git a/tests/unit/proxy/proxy-daemon-state.test.ts b/tests/unit/proxy/proxy-daemon-state.test.ts index c653b030..ba7d6444 100644 --- a/tests/unit/proxy/proxy-daemon-state.test.ts +++ b/tests/unit/proxy/proxy-daemon-state.test.ts @@ -3,11 +3,16 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { + getOpenAICompatProxyDir, getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, getOpenAICompatProxySessionPath, } from '../../../src/proxy/proxy-daemon-paths'; -import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state'; +import { + listOpenAICompatProxyProfileNames, + removeOpenAICompatProxyAuthTokenFile, + writeOpenAICompatProxyAuthTokenFile, +} from '../../../src/proxy/proxy-daemon-state'; let originalCcsHome: string | undefined; let tempDir: string; @@ -51,3 +56,24 @@ describe('listOpenAICompatProxyProfileNames', () => { expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']); }); }); + +describe('writeOpenAICompatProxyAuthTokenFile', () => { + it('writes a private token file under the CCS proxy dir and removes it', () => { + const authToken = 'proxy-token-regression-secret'; + const tokenPath = writeOpenAICompatProxyAuthTokenFile('ccg', authToken); + const proxyDir = getOpenAICompatProxyDir(); + + expect(path.dirname(tokenPath)).toBe(proxyDir); + expect(tokenPath.startsWith(`${proxyDir}${path.sep}`)).toBe(true); + expect(fs.readFileSync(tokenPath, 'utf8')).toBe(authToken); + + if (process.platform !== 'win32') { + expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600); + } + + removeOpenAICompatProxyAuthTokenFile(tokenPath); + + expect(fs.existsSync(tokenPath)).toBe(false); + }); +});