From d6c774336ba59774bd4fadc7563f456e5adf991c Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:27:22 -0500 Subject: [PATCH 1/5] feat: add profile routing config --- src/proxy/adapter.ts | 1 + src/proxy/adapters/opencode.ts | 4 +++ src/proxy/profiles.ts | 61 ++++++++++++++++++++++++++++++++++ src/proxy/types.ts | 18 ++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/proxy/profiles.ts diff --git a/src/proxy/adapter.ts b/src/proxy/adapter.ts index 5ae6878..ccd4d56 100644 --- a/src/proxy/adapter.ts +++ b/src/proxy/adapter.ts @@ -21,6 +21,7 @@ export interface AgentAdapter { * Returns undefined if the agent doesn't provide session tracking. */ getSessionId(c: Context): string | undefined + getProfileId(c: Context): string | undefined /** * Extract the client's working directory from the request body. diff --git a/src/proxy/adapters/opencode.ts b/src/proxy/adapters/opencode.ts index eae66b9..bddbd65 100644 --- a/src/proxy/adapters/opencode.ts +++ b/src/proxy/adapters/opencode.ts @@ -18,6 +18,10 @@ export const openCodeAdapter: AgentAdapter = { return c.req.header("x-opencode-session") }, + getProfileId(c: Context): string | undefined { + return c.req.header("x-meridian-profile") + }, + extractWorkingDirectory(body: any): string | undefined { return extractClientCwd(body) }, diff --git a/src/proxy/profiles.ts b/src/proxy/profiles.ts new file mode 100644 index 0000000..788e768 --- /dev/null +++ b/src/proxy/profiles.ts @@ -0,0 +1,61 @@ +import type { ProfileConfig, ProfileType, ProxyConfig } from "./types" + +export const DEFAULT_PROFILE_ID = "default" + +export interface ResolvedProfile { + id: string + type: ProfileType + claudeExecutable?: string + env: Record +} + +function getProfileType(profile: ProfileConfig): ProfileType { + return profile.type ?? "claude-max" +} + +function getEnvValue(directValue: string | undefined, envName: string | undefined): string | undefined { + if (directValue) return directValue + if (!envName) return undefined + return process.env[envName] +} + +function buildProfileEnv(profile: ProfileConfig): Record { + const type = getProfileType(profile) + + if (type === "api") { + return { + ANTHROPIC_API_KEY: getEnvValue(profile.apiKey, profile.apiKeyEnv), + ANTHROPIC_BASE_URL: profile.baseUrl, + ANTHROPIC_AUTH_TOKEN: getEnvValue(profile.authToken, profile.authTokenEnv), + } + } + + return { + ...(profile.claudeConfigDir ? { CLAUDE_CONFIG_DIR: profile.claudeConfigDir } : {}), + } +} + +export function resolveProfile(config: ProxyConfig, requestedProfileId?: string): ResolvedProfile { + const configuredProfiles = config.profiles ?? [] + + if (configuredProfiles.length === 0) { + return { + id: requestedProfileId ?? config.defaultProfile ?? DEFAULT_PROFILE_ID, + type: "claude-max", + env: {}, + } + } + + const resolvedId = requestedProfileId ?? config.defaultProfile ?? configuredProfiles[0]!.id + const profile = configuredProfiles.find((candidate) => candidate.id === resolvedId) + if (!profile) { + throw new Error(`Unknown profile: ${resolvedId}`) + } + + return { + id: profile.id, + type: getProfileType(profile), + claudeExecutable: profile.claudeExecutable, + env: buildProfileEnv(profile), + } +} diff --git a/src/proxy/types.ts b/src/proxy/types.ts index 8e51ec8..52da5c7 100644 --- a/src/proxy/types.ts +++ b/src/proxy/types.ts @@ -6,6 +6,22 @@ export interface ProxyConfig { debug: boolean idleTimeoutSeconds: number silent: boolean + profiles?: ProfileConfig[] + defaultProfile?: string +} + +export type ProfileType = "claude-max" | "api" + +export interface ProfileConfig { + id: string + type?: ProfileType + claudeConfigDir?: string + claudeExecutable?: string + apiKey?: string + apiKeyEnv?: string + baseUrl?: string + authToken?: string + authTokenEnv?: string } export interface ProxyInstance { @@ -31,4 +47,6 @@ export const DEFAULT_PROXY_CONFIG: ProxyConfig = { debug: process.env.CLAUDE_PROXY_DEBUG === "1", idleTimeoutSeconds: 120, silent: false, + profiles: undefined, + defaultProfile: undefined, } From 286fedc0d99468791a722da441e68ef8008d20aa Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:27:45 -0500 Subject: [PATCH 2/5] refactor: scope sessions and runtime by profile --- src/proxy/models.ts | 78 ++++++++++++++++++++++++++++-------- src/proxy/server.ts | 48 ++++++++++++---------- src/proxy/session/cache.ts | 71 ++++++++++++++++++++++---------- src/proxy/session/lineage.ts | 1 + src/proxy/sessionStore.ts | 12 +++++- 5 files changed, 148 insertions(+), 62 deletions(-) diff --git a/src/proxy/models.ts b/src/proxy/models.ts index edab596..40cf5f3 100644 --- a/src/proxy/models.ts +++ b/src/proxy/models.ts @@ -30,6 +30,41 @@ let cachedAuthStatusAt = 0 let cachedAuthStatusIsFailure = false let cachedAuthStatusPromise: Promise | null = null +type AuthStatusCache = { + cachedAuthStatus: ClaudeAuthStatus | null + lastKnownGoodAuthStatus: ClaudeAuthStatus | null + cachedAuthStatusAt: number + cachedAuthStatusIsFailure: boolean + cachedAuthStatusPromise: Promise | null +} + +const authStatusCacheByKey = new Map() + +function getAuthStatusCacheKey(envOverrides?: Record): string { + if (!envOverrides) return "default" + return JSON.stringify({ + CLAUDE_CONFIG_DIR: envOverrides.CLAUDE_CONFIG_DIR, + ANTHROPIC_API_KEY: envOverrides.ANTHROPIC_API_KEY, + ANTHROPIC_BASE_URL: envOverrides.ANTHROPIC_BASE_URL, + ANTHROPIC_AUTH_TOKEN: envOverrides.ANTHROPIC_AUTH_TOKEN, + }) +} + +function getAuthStatusCache(key: string): AuthStatusCache { + let cache = authStatusCacheByKey.get(key) + if (!cache) { + cache = { + cachedAuthStatus, + lastKnownGoodAuthStatus, + cachedAuthStatusAt, + cachedAuthStatusIsFailure, + cachedAuthStatusPromise, + } + authStatusCacheByKey.set(key, cache) + } + return cache +} + /** * Only Claude 4.6 models support the 1M extended context window. * Older models (4.5 and earlier) do not. @@ -72,39 +107,43 @@ export function hasExtendedContext(model: ClaudeModel): boolean { return model.endsWith("[1m]") } -export async function getClaudeAuthStatusAsync(): Promise { +export async function getClaudeAuthStatusAsync(envOverrides?: Record): Promise { + const cache = getAuthStatusCache(getAuthStatusCacheKey(envOverrides)) // Return cached result if within TTL — use shorter TTL for failures to recover faster - const ttl = cachedAuthStatusIsFailure ? AUTH_STATUS_FAILURE_TTL_MS : AUTH_STATUS_CACHE_TTL_MS - if (cachedAuthStatusAt > 0 && Date.now() - cachedAuthStatusAt < ttl) { + const ttl = cache.cachedAuthStatusIsFailure ? AUTH_STATUS_FAILURE_TTL_MS : AUTH_STATUS_CACHE_TTL_MS + if (cache.cachedAuthStatusAt > 0 && Date.now() - cache.cachedAuthStatusAt < ttl) { // On failure, return last known good status (preserves subscription type for model selection) - return cachedAuthStatus ?? lastKnownGoodAuthStatus + return cache.cachedAuthStatus ?? cache.lastKnownGoodAuthStatus } - if (cachedAuthStatusPromise) return cachedAuthStatusPromise + if (cache.cachedAuthStatusPromise) return cache.cachedAuthStatusPromise - cachedAuthStatusPromise = (async () => { + cache.cachedAuthStatusPromise = (async () => { try { - const { stdout } = await exec("claude auth status", { timeout: 5000 }) + const { stdout } = await exec("claude auth status", { + timeout: 5000, + env: { ...process.env, ...envOverrides }, + }) const parsed = JSON.parse(stdout) as ClaudeAuthStatus - cachedAuthStatus = parsed - lastKnownGoodAuthStatus = parsed - cachedAuthStatusAt = Date.now() - cachedAuthStatusIsFailure = false + cache.cachedAuthStatus = parsed + cache.lastKnownGoodAuthStatus = parsed + cache.cachedAuthStatusAt = Date.now() + cache.cachedAuthStatusIsFailure = false return parsed } catch { // Short-lived negative cache: retry in 5s instead of 60s. // Return last known good status so model selection doesn't degrade // (e.g. sonnet[1m] → sonnet) during transient auth command failures. - cachedAuthStatusIsFailure = true - cachedAuthStatusAt = Date.now() - cachedAuthStatus = null - return lastKnownGoodAuthStatus + cache.cachedAuthStatusIsFailure = true + cache.cachedAuthStatusAt = Date.now() + cache.cachedAuthStatus = null + return cache.lastKnownGoodAuthStatus } })() try { - return await cachedAuthStatusPromise + return await cache.cachedAuthStatusPromise } finally { - cachedAuthStatusPromise = null + cache.cachedAuthStatusPromise = null } } @@ -172,6 +211,7 @@ export function resetCachedClaudeAuthStatus(): void { cachedAuthStatusAt = 0 cachedAuthStatusIsFailure = false cachedAuthStatusPromise = null + authStatusCacheByKey.clear() } /** Expire the auth status cache without clearing lastKnownGoodAuthStatus — for testing only. @@ -180,6 +220,10 @@ export function resetCachedClaudeAuthStatus(): void { export function expireAuthStatusCache(): void { cachedAuthStatusAt = 0 cachedAuthStatusPromise = null + for (const cache of authStatusCacheByKey.values()) { + cache.cachedAuthStatusAt = 0 + cache.cachedAuthStatusPromise = null + } } /** diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 90bb239..5c718e3 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -25,6 +25,7 @@ import { ALLOWED_MCP_TOOLS } from "./tools" import { getLastUserMessage } from "./messages" import { openCodeAdapter } from "./adapters/opencode" import { buildQueryOptions, type QueryContext } from "./query" +import { resolveProfile } from "./profiles" import { computeLineageHash, hashMessage, @@ -181,11 +182,18 @@ export function createProxyServer(config: Partial = {}): ProxyServe return withClaudeLogContext({ requestId: requestMeta.requestId, endpoint: requestMeta.endpoint }, async () => { try { const body = await c.req.json() - const authStatus = await getClaudeAuthStatusAsync() - let model = mapModelToClaudeModel(body.model || "sonnet", authStatus?.subscriptionType) const stream = body.stream ?? true const adapter = openCodeAdapter + const requestedProfile = resolveProfile(finalConfig, adapter.getProfileId(c)) const workingDirectory = process.env.CLAUDE_PROXY_WORKDIR || adapter.extractWorkingDirectory(body) || process.cwd() + const opencodeSessionId = adapter.getSessionId(c) + const lineageResult = lookupSession(opencodeSessionId, body.messages || [], workingDirectory, requestedProfile.id) + const isResume = lineageResult.type === "continuation" || lineageResult.type === "compaction" + const isUndo = lineageResult.type === "undo" + const cachedSession = lineageResult.type !== "diverged" ? lineageResult.session : undefined + const effectiveProfile = resolveProfile(finalConfig, cachedSession?.profileId || requestedProfile.id) + const authStatus = await getClaudeAuthStatusAsync(effectiveProfile.env) + let model = mapModelToClaudeModel(body.model || "sonnet", authStatus?.subscriptionType) // Strip env vars that would cause the SDK subprocess to loop back through // the proxy instead of using its native Claude Max auth. Also strip vars @@ -197,6 +205,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe ANTHROPIC_AUTH_TOKEN: _dropAuthToken, ...cleanEnv } = process.env + const runtimeEnv = { ...cleanEnv, ...effectiveProfile.env } + let runtimeClaudeExecutable = effectiveProfile.claudeExecutable || claudeExecutable let systemContext = "" if (body.system) { @@ -210,12 +220,6 @@ export function createProxyServer(config: Partial = {}): ProxyServe } } - // Session resume: look up cached Claude SDK session and classify mutation - const opencodeSessionId = adapter.getSessionId(c) - const lineageResult = lookupSession(opencodeSessionId, body.messages || [], workingDirectory) - const isResume = lineageResult.type === "continuation" || lineageResult.type === "compaction" - const isUndo = lineageResult.type === "undo" - const cachedSession = lineageResult.type !== "diverged" ? lineageResult.session : undefined const resumeSessionId = cachedSession?.claudeSessionId // For undo: fork the session at the rollback point const undoRollbackUuid = isUndo && lineageResult.type === "undo" ? lineageResult.rollbackUuid : undefined @@ -478,8 +482,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe try { // Lazy-resolve executable if not already set (e.g. when using createProxyServer directly) - if (!claudeExecutable) { - claudeExecutable = await resolveClaudeExecutableAsync() + if (!runtimeClaudeExecutable) { + runtimeClaudeExecutable = await resolveClaudeExecutableAsync() } // Wrap SDK call with transparent retry for recoverable errors. @@ -502,8 +506,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe let didYieldContent = false try { for await (const event of query(buildQueryOptions({ - prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv, + prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId, isUndo, undoRollbackUuid, sdkHooks, adapter, }))) { if ((event as any).type === "assistant") { @@ -526,13 +530,13 @@ export function createProxyServer(config: Partial = {}): ProxyServe resumeSessionId, }) console.error(`[PROXY] Stale session UUID, evicting and retrying as fresh session`) - evictSession(opencodeSessionId, workingDirectory, allMessages) + evictSession(opencodeSessionId, workingDirectory, allMessages, requestedProfile.id) sdkUuidMap.length = 0 for (let i = 0; i < allMessages.length; i++) sdkUuidMap.push(null) yield* query(buildQueryOptions({ prompt: buildFreshPrompt(allMessages, stripCacheControl), - model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv, + model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: false, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId: undefined, isUndo: false, undoRollbackUuid: undefined, sdkHooks, adapter, })) return @@ -684,7 +688,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Store session for future resume if (currentSessionId) { - storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap) + storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap, requestedProfile.id, effectiveProfile.id) } const responseSessionId = currentSessionId || resumeSessionId || `session_${Date.now()}` @@ -768,8 +772,8 @@ export function createProxyServer(config: Partial = {}): ProxyServe let didYieldClientEvent = false try { for await (const event of query(buildQueryOptions({ - prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv, + prompt: makePrompt(), model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId, isUndo, undoRollbackUuid, sdkHooks, adapter, }))) { if ((event as any).type === "stream_event") { @@ -792,13 +796,13 @@ export function createProxyServer(config: Partial = {}): ProxyServe resumeSessionId, }) console.error(`[PROXY] Stale session UUID, evicting and retrying as fresh session`) - evictSession(opencodeSessionId, workingDirectory, allMessages) + evictSession(opencodeSessionId, workingDirectory, allMessages, requestedProfile.id) sdkUuidMap.length = 0 for (let i = 0; i < allMessages.length; i++) sdkUuidMap.push(null) yield* query(buildQueryOptions({ prompt: buildFreshPrompt(allMessages, stripCacheControl), - model, workingDirectory, systemContext, claudeExecutable, - passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv, + model, workingDirectory, systemContext, claudeExecutable: runtimeClaudeExecutable, + passthrough, stream: true, sdkAgents, passthroughMcp, cleanEnv: runtimeEnv, resumeSessionId: undefined, isUndo: false, undoRollbackUuid: undefined, sdkHooks, adapter, })) return @@ -971,7 +975,7 @@ export function createProxyServer(config: Partial = {}): ProxyServe // Store session for future resume if (currentSessionId) { - storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap) + storeSession(opencodeSessionId, body.messages || [], currentSessionId, workingDirectory, sdkUuidMap, requestedProfile.id, effectiveProfile.id) } if (!streamClosed) { diff --git a/src/proxy/session/cache.ts b/src/proxy/session/cache.ts index cc49933..5989521 100644 --- a/src/proxy/session/cache.ts +++ b/src/proxy/session/cache.ts @@ -19,6 +19,7 @@ import { // --- Cache setup --- const DEFAULT_MAX_SESSIONS = 1000 +const IMPLICIT_DEFAULT_PROFILE_ID = "default" export function getMaxSessionsLimit(): number { const raw = process.env.CLAUDE_PROXY_MAX_SESSIONS @@ -33,6 +34,11 @@ export function getMaxSessionsLimit(): number { return parsed } +function buildScopedKey(key: string, profileId?: string): string { + if (!profileId || profileId === IMPLICIT_DEFAULT_PROFILE_ID) return key + return `${profileId}:${key}` +} + function removeFingerprintEntriesByClaudeSessionId(claudeSessionId: string): void { for (const [key, state] of fingerprintCache.entries()) { if (state.claudeSessionId === claudeSessionId) { @@ -89,25 +95,28 @@ export function clearSessionCache() { export function evictSession( sessionId: string | undefined, workingDirectory?: string, - messages?: Array<{ role: string; content: any }> + messages?: Array<{ role: string; content: any }>, + profileId?: string ): void { if (sessionId) { - const cached = sessionCache.get(sessionId) + const scopedSessionId = buildScopedKey(sessionId, profileId) + const cached = sessionCache.get(scopedSessionId) if (cached) { removeFingerprintEntriesByClaudeSessionId(cached.claudeSessionId) - sessionCache.delete(sessionId) + sessionCache.delete(scopedSessionId) } - try { evictSharedSession(sessionId) } catch {} + try { evictSharedSession(scopedSessionId) } catch {} } if (messages) { const fp = getConversationFingerprint(messages, workingDirectory) if (fp) { - const cached = fingerprintCache.get(fp) + const scopedFingerprint = buildScopedKey(fp, profileId) + const cached = fingerprintCache.get(scopedFingerprint) if (cached) { removeSessionEntriesByClaudeSessionId(cached.claudeSessionId) - fingerprintCache.delete(fp) + fingerprintCache.delete(scopedFingerprint) } - try { evictSharedSession(fp) } catch {} + try { evictSharedSession(scopedFingerprint) } catch {} } } } @@ -126,28 +135,31 @@ function touchSession(state: SessionState): SessionState { export function lookupSession( sessionId: string | undefined, messages: Array<{ role: string; content: any }>, - workingDirectory?: string + workingDirectory?: string, + profileId?: string ): LineageResult { if (sessionId) { - const cached = sessionCache.get(sessionId) + const scopedSessionId = buildScopedKey(sessionId, profileId) + const cached = sessionCache.get(scopedSessionId) if (cached) { - const result = verifyLineage(cached, messages, sessionId, sessionCache) + const result = verifyLineage(cached, messages, scopedSessionId, sessionCache) if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } - const shared = lookupSharedSession(sessionId) + const shared = lookupSharedSession(scopedSessionId) if (shared) { const state: SessionState = { claudeSessionId: shared.claudeSessionId, + profileId: shared.profileId, lastAccess: Date.now(), messageCount: shared.messageCount || 0, lineageHash: shared.lineageHash || "", messageHashes: shared.messageHashes, sdkMessageUuids: shared.sdkMessageUuids, } - const result = verifyLineage(state, messages, sessionId, sessionCache) + const result = verifyLineage(state, messages, scopedSessionId, sessionCache) if (result.type === "continuation" || result.type === "compaction") { - sessionCache.set(sessionId, state) + sessionCache.set(scopedSessionId, state) } return result } @@ -156,25 +168,27 @@ export function lookupSession( const fp = getConversationFingerprint(messages, workingDirectory) if (fp) { - const cached = fingerprintCache.get(fp) + const scopedFingerprint = buildScopedKey(fp, profileId) + const cached = fingerprintCache.get(scopedFingerprint) if (cached) { - const result = verifyLineage(cached, messages, fp, fingerprintCache) + const result = verifyLineage(cached, messages, scopedFingerprint, fingerprintCache) if (result.type === "continuation" || result.type === "compaction") touchSession(result.session) return result } - const shared = lookupSharedSession(fp) + const shared = lookupSharedSession(scopedFingerprint) if (shared) { const state: SessionState = { claudeSessionId: shared.claudeSessionId, + profileId: shared.profileId, lastAccess: Date.now(), messageCount: shared.messageCount || 0, lineageHash: shared.lineageHash || "", messageHashes: shared.messageHashes, sdkMessageUuids: shared.sdkMessageUuids, } - const result = verifyLineage(state, messages, fp, fingerprintCache) + const result = verifyLineage(state, messages, scopedFingerprint, fingerprintCache) if (result.type === "continuation" || result.type === "compaction") { - fingerprintCache.set(fp, state) + fingerprintCache.set(scopedFingerprint, state) } return result } @@ -190,13 +204,16 @@ export function storeSession( messages: Array<{ role: string; content: any }>, claudeSessionId: string, workingDirectory?: string, - sdkMessageUuids?: Array + sdkMessageUuids?: Array, + profileId?: string, + effectiveProfileId?: string ) { if (!claudeSessionId) return const lineageHash = computeLineageHash(messages) const messageHashes = computeMessageHashes(messages) const state: SessionState = { claudeSessionId, + profileId: effectiveProfileId, lastAccess: Date.now(), messageCount: messages?.length || 0, lineageHash, @@ -204,10 +221,20 @@ export function storeSession( sdkMessageUuids, } // In-memory cache - if (sessionId) sessionCache.set(sessionId, state) + if (sessionId) sessionCache.set(buildScopedKey(sessionId, profileId), state) const fp = getConversationFingerprint(messages, workingDirectory) - if (fp) fingerprintCache.set(fp, state) + if (fp) fingerprintCache.set(buildScopedKey(fp, profileId), state) // Shared file store (cross-proxy resume) const key = sessionId || fp - if (key) storeSharedSession(key, claudeSessionId, state.messageCount, lineageHash, messageHashes, sdkMessageUuids) + if (key) { + storeSharedSession( + buildScopedKey(key, profileId), + claudeSessionId, + state.messageCount, + lineageHash, + messageHashes, + sdkMessageUuids, + effectiveProfileId, + ) + } } diff --git a/src/proxy/session/lineage.ts b/src/proxy/session/lineage.ts index d96d308..5ff52e3 100644 --- a/src/proxy/session/lineage.ts +++ b/src/proxy/session/lineage.ts @@ -17,6 +17,7 @@ export const MIN_SUFFIX_FOR_COMPACTION = 2 export interface SessionState { claudeSessionId: string + profileId?: string lastAccess: number messageCount: number /** Hash of messages[0..messageCount-1] for fast-path lineage verification. diff --git a/src/proxy/sessionStore.ts b/src/proxy/sessionStore.ts index 240d520..17515f1 100644 --- a/src/proxy/sessionStore.ts +++ b/src/proxy/sessionStore.ts @@ -26,6 +26,7 @@ import { join } from "node:path" export interface StoredSession { claudeSessionId: string + profileId?: string createdAt: number lastUsedAt: number messageCount: number @@ -131,7 +132,15 @@ export function lookupSharedSession(key: string): StoredSession | undefined { return store[key] } -export function storeSharedSession(key: string, claudeSessionId: string, messageCount?: number, lineageHash?: string, messageHashes?: string[], sdkMessageUuids?: Array): void { +export function storeSharedSession( + key: string, + claudeSessionId: string, + messageCount?: number, + lineageHash?: string, + messageHashes?: string[], + sdkMessageUuids?: Array, + profileId?: string +): void { const path = getStorePath() const lockPath = `${path}.lock` const hasLock = acquireLock(lockPath) @@ -143,6 +152,7 @@ export function storeSharedSession(key: string, claudeSessionId: string, message const existing = store[key] store[key] = { claudeSessionId, + profileId: profileId ?? existing?.profileId, createdAt: existing?.createdAt || Date.now(), lastUsedAt: Date.now(), messageCount: messageCount ?? existing?.messageCount ?? 0, From 516bf91bb473a2475d89d08ea4ee9f33de338d3d Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:28:08 -0500 Subject: [PATCH 3/5] test: cover profile routing behavior --- src/__tests__/adapter.test.ts | 9 ++ src/__tests__/proxy-profiles.test.ts | 164 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/__tests__/proxy-profiles.test.ts diff --git a/src/__tests__/adapter.test.ts b/src/__tests__/adapter.test.ts index 51f3b42..7675669 100644 --- a/src/__tests__/adapter.test.ts +++ b/src/__tests__/adapter.test.ts @@ -25,6 +25,15 @@ describe("openCodeAdapter", () => { expect(openCodeAdapter.getSessionId(mockContext as any)).toBeUndefined() }) + it("extracts profile ID from x-meridian-profile header", () => { + const mockContext = { + req: { + header: (name: string) => name === "x-meridian-profile" ? "company" : undefined + } + } + expect(openCodeAdapter.getProfileId(mockContext as any)).toBe("company") + }) + it("extracts working directory from system prompt env block", () => { const body = { system: "\n Working directory: /Users/test/project\n" diff --git a/src/__tests__/proxy-profiles.test.ts b/src/__tests__/proxy-profiles.test.ts new file mode 100644 index 0000000..dfb7573 --- /dev/null +++ b/src/__tests__/proxy-profiles.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" + +let capturedQueryOptions: any[] = [] + +mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: (params: any) => { + capturedQueryOptions.push(params.options) + const profileDir = params.options?.env?.CLAUDE_CONFIG_DIR + const sessionSuffix = typeof profileDir === "string" + ? profileDir.split("/").at(-1) + : params.options?.env?.ANTHROPIC_API_KEY + ? "api" + : "default" + + return (async function* () { + yield { + type: "assistant", + message: { + id: `msg-${sessionSuffix}`, + type: "message", + role: "assistant", + content: [{ type: "text", text: `ok-${sessionSuffix}` }], + model: "claude-sonnet-4-5", + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + session_id: `sdk-session-${sessionSuffix}`, + } + })() + }, + createSdkMcpServer: () => ({ type: "sdk", name: "test", instance: {} }), +})) + +mock.module("../logger", () => ({ + claudeLog: () => {}, + withClaudeLogContext: (_ctx: any, fn: any) => fn(), +})) + +mock.module("../mcpTools", () => ({ + createOpencodeMcpServer: () => ({ type: "sdk", name: "opencode", instance: {} }), +})) + +mock.module("../proxy/models", () => ({ + mapModelToClaudeModel: () => "sonnet", + resolveClaudeExecutableAsync: async () => "/usr/bin/claude", + isClosedControllerError: () => false, + getClaudeAuthStatusAsync: async () => ({ loggedIn: true, subscriptionType: "max" }), + hasExtendedContext: () => false, + stripExtendedContext: (model: string) => model, +})) + +const { createProxyServer, clearSessionCache } = await import("../proxy/server") + +function createTestApp(config: Record = {}) { + const { app } = createProxyServer({ port: 0, host: "127.0.0.1", ...config }) + return app +} + +async function post(app: any, body: any, headers: Record = {}) { + return app.fetch(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + })) +} + +const BASIC_REQUEST = { + model: "claude-sonnet-4-5", + max_tokens: 128, + stream: false, + messages: [{ role: "user", content: "hello" }], +} + +describe("Profile routing", () => { + const savedEnv: Record = {} + + beforeEach(() => { + capturedQueryOptions = [] + clearSessionCache() + for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]) { + savedEnv[key] = process.env[key] + } + }) + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }) + + it("passes CLAUDE_CONFIG_DIR for a claude-max profile", async () => { + const app = createTestApp({ + profiles: [{ id: "company", claudeConfigDir: "/tmp/company-profile" }], + defaultProfile: "company", + }) + + await (await post(app, BASIC_REQUEST)).json() + + expect(capturedQueryOptions[0]?.env?.CLAUDE_CONFIG_DIR).toBe("/tmp/company-profile") + }) + + it("overlays API profile auth env after inherited Anthropic vars are stripped", async () => { + process.env.ANTHROPIC_API_KEY = "dummy-key" + process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:3456" + process.env.ANTHROPIC_AUTH_TOKEN = "dummy-token" + + const app = createTestApp({ + profiles: [{ + id: "api-direct", + type: "api", + apiKey: "real-key", + baseUrl: "https://api.example.test", + authToken: "real-token", + }], + defaultProfile: "api-direct", + }) + + await (await post(app, BASIC_REQUEST)).json() + + expect(capturedQueryOptions[0]?.env?.ANTHROPIC_API_KEY).toBe("real-key") + expect(capturedQueryOptions[0]?.env?.ANTHROPIC_BASE_URL).toBe("https://api.example.test") + expect(capturedQueryOptions[0]?.env?.ANTHROPIC_AUTH_TOKEN).toBe("real-token") + }) + + it("keeps session resume isolated by requested profile", async () => { + const app = createTestApp({ + profiles: [ + { id: "personal", claudeConfigDir: "/tmp/personal-profile" }, + { id: "company", claudeConfigDir: "/tmp/company-profile" }, + ], + defaultProfile: "personal", + }) + + await (await post(app, BASIC_REQUEST, { + "x-opencode-session": "shared-session", + "x-meridian-profile": "personal", + })).json() + + await (await post(app, BASIC_REQUEST, { + "x-opencode-session": "shared-session", + "x-meridian-profile": "company", + })).json() + + await (await post(app, { + ...BASIC_REQUEST, + messages: [ + { role: "user", content: "hello" }, + { role: "assistant", content: [{ type: "text", text: "ok-personal-profile" }] }, + { role: "user", content: "remember me" }, + ], + }, { + "x-opencode-session": "shared-session", + "x-meridian-profile": "personal", + })).json() + + expect(capturedQueryOptions[0]?.resume).toBeUndefined() + expect(capturedQueryOptions[1]?.resume).toBeUndefined() + expect(capturedQueryOptions[2]?.resume).toBe("sdk-session-personal-profile") + }) +}) From 92c506e7588b6b4045e14ede21a65c070b7003a6 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 17:37:51 -0500 Subject: [PATCH 4/5] docs: add local profile routing example --- examples/profile-routing-local.ts | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 examples/profile-routing-local.ts diff --git a/examples/profile-routing-local.ts b/examples/profile-routing-local.ts new file mode 100644 index 0000000..78be674 --- /dev/null +++ b/examples/profile-routing-local.ts @@ -0,0 +1,33 @@ +import { startProxyServer } from "../src/proxy/server" + +const port = Number.parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10) +const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1" + +const personalDir = process.env.MERIDIAN_PERSONAL_CLAUDE_DIR +const companyDir = process.env.MERIDIAN_COMPANY_CLAUDE_DIR + +if (!personalDir || !companyDir) { + throw new Error("Set MERIDIAN_PERSONAL_CLAUDE_DIR and MERIDIAN_COMPANY_CLAUDE_DIR before running this example") +} + +const proxy = await startProxyServer({ + port, + host, + profiles: [ + { id: "personal", claudeConfigDir: personalDir }, + { id: "company", claudeConfigDir: companyDir }, + ], + defaultProfile: "personal", +}) + +const stop = async () => { + await proxy.close() + process.exit(0) +} + +process.on("SIGINT", () => { void stop() }) +process.on("SIGTERM", () => { void stop() }) + +console.log(`Profile test proxy running at http://${host}:${port}`) +console.log("Profiles: personal, company") +console.log("Use x-meridian-profile to select a profile per request") From 079152fd4fa1456e6e277af4b126fda1189e3aea Mon Sep 17 00:00:00 2001 From: David Date: Thu, 26 Mar 2026 18:38:47 -0500 Subject: [PATCH 5/5] feat: add config-based request authentication --- Dockerfile | 2 +- README.md | 68 +++++++++++++++ bin/cli.ts | 31 +++++-- bin/docker-entrypoint.sh | 3 + examples/meridian.config.example.json | 19 +++++ src/__tests__/configLoader.test.ts | 95 +++++++++++++++++++++ src/__tests__/proxy-async-ops.test.ts | 38 +++++++++ src/__tests__/proxy-auth.test.ts | 118 ++++++++++++++++++++++++++ src/proxy/auth.ts | 22 +++++ src/proxy/configLoader.ts | 88 +++++++++++++++++++ src/proxy/server.ts | 33 +++++++ src/proxy/types.ts | 11 +++ 12 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 examples/meridian.config.example.json create mode 100644 src/__tests__/configLoader.test.ts create mode 100644 src/__tests__/proxy-auth.test.ts create mode 100644 src/proxy/auth.ts create mode 100644 src/proxy/configLoader.ts diff --git a/Dockerfile b/Dockerfile index 742288a..853eca5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ FROM node:22-alpine RUN deluser --remove-home node 2>/dev/null; \ adduser -D -u 1000 claude \ - && mkdir -p /home/claude/.claude \ + && mkdir -p /home/claude/.claude /home/claude/.config/meridian \ && chown -R claude:claude /home/claude RUN npm install -g @anthropic-ai/claude-code \ diff --git a/README.md b/README.md index da25693..722d9e3 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,42 @@ See [`adapters/opencode.ts`](src/proxy/adapters/opencode.ts) for reference. ## Configuration +Meridian can load a JSON config file from either: + +- `~/.config/meridian/config.json` +- `./meridian.config.json` + +Set `CLAUDE_PROXY_CONFIG=/path/to/config.json` to use an explicit file. + +Example: + +```json +{ + "port": 4567, + "defaultProfile": "personal", + "requiredApiKeys": ["env:MERIDIAN_LAPTOP_KEY", "env:MERIDIAN_DESKTOP_KEY"], + "profiles": [ + { "id": "personal", "claudeConfigDir": "~/.claude" }, + { "id": "company", "claudeConfigDir": "~/.claude-company" } + ] +} +``` + +Plaintext keys also work if you want a fully self-contained local config: + +```json +{ + "requiredApiKeys": ["laptop-secret", "desktop-secret"] +} +``` + +That is supported, but safer practice is to keep secret values in env vars and reference them from JSON. + +String values support: + +- `~/...` home expansion +- `env:NAME` or `$env:NAME` environment variable expansion + | Variable | Default | Description | |----------|---------|-------------| | `CLAUDE_PROXY_PORT` | `3456` | Port to listen on | @@ -177,6 +213,8 @@ See [`adapters/opencode.ts`](src/proxy/adapters/opencode.ts) for reference. | `CLAUDE_PROXY_WORKDIR` | `cwd()` | Default working directory for SDK | | `CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS` | `120` | HTTP keep-alive timeout | | `CLAUDE_PROXY_TELEMETRY_SIZE` | `1000` | Telemetry ring buffer size | +| `CLAUDE_PROXY_CONFIG` | unset | Explicit path to a JSON config file | +| `CLAUDE_PROXY_API_KEYS` | unset | Comma-separated allowed inbound API keys | ## Programmatic API @@ -255,12 +293,42 @@ See [`examples/opencode-plugin/`](examples/opencode-plugin/) for a reference imp docker run -v ~/.claude:/home/claude/.claude -p 3456:3456 meridian ``` +To use config-file-driven profiles and API keys in Docker, mount your config to the default path inside the container: + +```bash +docker run \ + -v ~/.claude:/home/claude/.claude \ + -v ~/.config/meridian/config.json:/home/claude/.config/meridian/config.json:ro \ + -e MERIDIAN_LAPTOP_KEY="$MERIDIAN_LAPTOP_KEY" \ + -e MERIDIAN_DESKTOP_KEY="$MERIDIAN_DESKTOP_KEY" \ + -p 3456:3456 \ + meridian +``` + +If you prefer plaintext keys in the mounted JSON, you can omit the extra env vars and keep the file fully self-contained. + Or with docker-compose: ```bash docker compose up -d ``` +Example `docker-compose.yml` service override: + +```yaml +services: + proxy: + environment: + CLAUDE_PROXY_CONFIG: /home/claude/.config/meridian/config.json + MERIDIAN_LAPTOP_KEY: ${MERIDIAN_LAPTOP_KEY} + MERIDIAN_DESKTOP_KEY: ${MERIDIAN_DESKTOP_KEY} + volumes: + - claude-auth:/home/claude/.claude + - ./meridian.config.json:/home/claude/.config/meridian/config.json:ro +``` + +The container now creates `/home/claude/.config/meridian` automatically, so the default config-file path works without extra setup. + ## Testing ```bash diff --git a/bin/cli.ts b/bin/cli.ts index a8faca7..c907c2d 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node import { startProxyServer } from "../src/proxy/server" +import { loadProxyConfigFile } from "../src/proxy/configLoader" import { exec as execCallback } from "child_process" import { promisify } from "util" +import type { ProxyConfig } from "../src/proxy/types" const exec = promisify(execCallback) @@ -14,9 +16,21 @@ process.on("unhandledRejection", (reason) => { console.error(`[PROXY] Unhandled rejection (recovered): ${reason instanceof Error ? reason.message : reason}`) }) -const port = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10) -const host = process.env.CLAUDE_PROXY_HOST || "127.0.0.1" -const idleTimeoutSeconds = parseInt(process.env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS || "120", 10) +function getEnvConfigOverrides(env: NodeJS.ProcessEnv = process.env): Partial { + const overrides: Partial = {} + + if (env.CLAUDE_PROXY_PORT) overrides.port = parseInt(env.CLAUDE_PROXY_PORT, 10) + if (env.CLAUDE_PROXY_HOST) overrides.host = env.CLAUDE_PROXY_HOST + if (env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS) { + overrides.idleTimeoutSeconds = parseInt(env.CLAUDE_PROXY_IDLE_TIMEOUT_SECONDS, 10) + } + if (env.CLAUDE_PROXY_DEBUG) overrides.debug = env.CLAUDE_PROXY_DEBUG === "1" + if (env.CLAUDE_PROXY_API_KEYS) { + overrides.requiredApiKeys = env.CLAUDE_PROXY_API_KEYS.split(",").map((key) => key.trim()).filter(Boolean) + } + + return overrides +} export async function runCli( start = startProxyServer, @@ -37,7 +51,9 @@ export async function runCli( console.error("\x1b[33m⚠ Could not verify Claude auth status. If requests fail, run: claude login\x1b[0m") } - const proxy = await start({ port, host, idleTimeoutSeconds }) + const fileConfig = loadProxyConfigFile() + const envOverrides = getEnvConfigOverrides() + const proxy = await start({ ...fileConfig, ...envOverrides }) // Handle EADDRINUSE — preserve CLI behavior of exiting on port conflict proxy.server.on("error", (error: NodeJS.ErrnoException) => { @@ -48,5 +64,10 @@ export async function runCli( } if (import.meta.main) { - await runCli() + try { + await runCli() + } catch (error) { + console.error(`[PROXY] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } } diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh index 3529f38..485949a 100755 --- a/bin/docker-entrypoint.sh +++ b/bin/docker-entrypoint.sh @@ -6,6 +6,9 @@ CLAUDE_DIR="/home/claude/.claude" CLAUDE_JSON="/home/claude/.claude.json" CLAUDE_JSON_VOL="$CLAUDE_DIR/.claude.json" +MERIDIAN_CONFIG_DIR="/home/claude/.config/meridian" + +mkdir -p "$MERIDIAN_CONFIG_DIR" # Fix ownership if volume was created as root if [ -d "$CLAUDE_DIR" ] && [ ! -w "$CLAUDE_DIR" ]; then diff --git a/examples/meridian.config.example.json b/examples/meridian.config.example.json new file mode 100644 index 0000000..ce57e52 --- /dev/null +++ b/examples/meridian.config.example.json @@ -0,0 +1,19 @@ +{ + "port": 4567, + "host": "127.0.0.1", + "defaultProfile": "personal", + "requiredApiKeys": [ + "env:MERIDIAN_LAPTOP_KEY", + "env:MERIDIAN_DESKTOP_KEY" + ], + "profiles": [ + { + "id": "personal", + "claudeConfigDir": "~/.claude" + }, + { + "id": "company", + "claudeConfigDir": "~/.claude-company" + } + ] +} diff --git a/src/__tests__/configLoader.test.ts b/src/__tests__/configLoader.test.ts new file mode 100644 index 0000000..3c2825c --- /dev/null +++ b/src/__tests__/configLoader.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { loadProxyConfigFile } from "../proxy/configLoader" + +describe("configLoader", () => { + const envKeys = ["CLAUDE_PROXY_CONFIG", "MERIDIAN_SHARED_KEY", "MERIDIAN_API_KEY"] as const + const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])) + + afterEach(() => { + for (const [key, value] of originalEnv.entries()) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }) + + it("loads the default config file from the current working directory", () => { + const cwd = mkdtempSync(join(tmpdir(), "meridian-config-cwd-")) + + try { + writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({ + defaultProfile: "company", + requiredApiKeys: ["alpha", "beta"], + })) + + const config = loadProxyConfigFile({ cwd, homeDir: cwd }) + expect(config.defaultProfile).toBe("company") + expect(config.requiredApiKeys).toEqual(["alpha", "beta"]) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) + + it("merges home config first and lets cwd config override it", () => { + const cwd = mkdtempSync(join(tmpdir(), "meridian-config-merge-cwd-")) + const homeDir = mkdtempSync(join(tmpdir(), "meridian-config-merge-home-")) + + try { + mkdirSync(join(homeDir, ".config", "meridian"), { recursive: true }) + writeFileSync(join(homeDir, ".config", "meridian", "config.json"), JSON.stringify({ + defaultProfile: "personal", + requiredApiKeys: ["alpha"], + })) + writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({ + defaultProfile: "company", + })) + + const config = loadProxyConfigFile({ cwd, homeDir }) + expect(config.defaultProfile).toBe("company") + expect(config.requiredApiKeys).toEqual(["alpha"]) + } finally { + rmSync(cwd, { recursive: true, force: true }) + rmSync(homeDir, { recursive: true, force: true }) + } + }) + + it("resolves env references and home paths in config values", () => { + const cwd = mkdtempSync(join(tmpdir(), "meridian-config-env-cwd-")) + const homeDir = mkdtempSync(join(tmpdir(), "meridian-config-env-home-")) + + try { + process.env.MERIDIAN_SHARED_KEY = "shared-secret" + process.env.MERIDIAN_API_KEY = "profile-secret" + + writeFileSync(join(cwd, "meridian.config.json"), JSON.stringify({ + requiredApiKeys: ["env:MERIDIAN_SHARED_KEY"], + profiles: [{ + id: "company", + type: "api", + apiKey: "$env:MERIDIAN_API_KEY", + claudeConfigDir: "~/.claude-company", + }], + })) + + const config = loadProxyConfigFile({ cwd, homeDir }) + expect(config.requiredApiKeys).toEqual(["shared-secret"]) + expect(config.profiles?.[0]?.apiKey).toBe("profile-secret") + expect(config.profiles?.[0]?.claudeConfigDir).toBe(join(homeDir, ".claude-company")) + } finally { + rmSync(cwd, { recursive: true, force: true }) + rmSync(homeDir, { recursive: true, force: true }) + } + }) + + it("throws when an explicit config path is missing", () => { + const cwd = mkdtempSync(join(tmpdir(), "meridian-config-missing-cwd-")) + + try { + expect(() => loadProxyConfigFile({ cwd, homeDir: cwd, configPath: "missing.json" })).toThrow("Config file not found") + } finally { + rmSync(cwd, { recursive: true, force: true }) + } + }) +}) diff --git a/src/__tests__/proxy-async-ops.test.ts b/src/__tests__/proxy-async-ops.test.ts index c9dc07b..17f30f7 100644 --- a/src/__tests__/proxy-async-ops.test.ts +++ b/src/__tests__/proxy-async-ops.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" const { createProxyServer, startProxyServer } = await import("../proxy/server") const { resetCachedClaudeAuthStatus } = await import("../proxy/models") @@ -92,4 +95,39 @@ describe("proxy async ops", () => { expect(startCalled).toBe(1) expect(errors.some((line) => line.includes("Could not verify Claude auth status"))).toBe(true) }) + + it("loads config from a JSON file before starting the CLI proxy", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "meridian-cli-config-")) + const originalConfigPath = process.env.CLAUDE_PROXY_CONFIG + const configPath = join(tmpDir, "meridian.config.json") + writeFileSync(configPath, JSON.stringify({ + port: 8123, + host: "0.0.0.0", + defaultProfile: "company", + requiredApiKeys: ["alpha", "beta"], + })) + + let capturedConfig: any + try { + process.env.CLAUDE_PROXY_CONFIG = configPath + + await runCli( + async (config) => { + capturedConfig = config + const { EventEmitter } = await import("events") + return { server: new EventEmitter(), config: {}, close: async () => {} } as any + }, + ((async () => ({ stdout: JSON.stringify({ loggedIn: true, subscriptionType: "max" }) })) as any), + ) + } finally { + if (originalConfigPath === undefined) delete process.env.CLAUDE_PROXY_CONFIG + else process.env.CLAUDE_PROXY_CONFIG = originalConfigPath + rmSync(tmpDir, { recursive: true, force: true }) + } + + expect(capturedConfig.port).toBe(8123) + expect(capturedConfig.host).toBe("0.0.0.0") + expect(capturedConfig.defaultProfile).toBe("company") + expect(capturedConfig.requiredApiKeys).toEqual(["alpha", "beta"]) + }) }) diff --git a/src/__tests__/proxy-auth.test.ts b/src/__tests__/proxy-auth.test.ts new file mode 100644 index 0000000..c2ca209 --- /dev/null +++ b/src/__tests__/proxy-auth.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +let queryCallCount = 0 + +mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: () => { + queryCallCount += 1 + return (async function* () { + yield { + type: "assistant", + message: { + id: "msg-auth", + type: "message", + role: "assistant", + content: [{ type: "text", text: "ok" }], + model: "claude-sonnet-4-5", + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + session_id: "sdk-auth", + } + })() + }, + createSdkMcpServer: () => ({ type: "sdk", name: "test", instance: {} }), +})) + +mock.module("../logger", () => ({ + claudeLog: () => {}, + withClaudeLogContext: (_ctx: any, fn: any) => fn(), +})) + +mock.module("../mcpTools", () => ({ + createOpencodeMcpServer: () => ({ type: "sdk", name: "opencode", instance: {} }), +})) + +mock.module("../proxy/models", () => ({ + mapModelToClaudeModel: () => "sonnet", + resolveClaudeExecutableAsync: async () => "/usr/bin/claude", + isClosedControllerError: () => false, + getClaudeAuthStatusAsync: async () => ({ loggedIn: true, subscriptionType: "max" }), + hasExtendedContext: () => false, + stripExtendedContext: (model: string) => model, +})) + +const { createProxyServer } = await import("../proxy/server") + +function createTestApp(config: Record = {}) { + const { app } = createProxyServer({ port: 0, host: "127.0.0.1", ...config }) + return app +} + +async function post(app: any, headers: Record = {}) { + return app.fetch(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5", + max_tokens: 64, + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + })) +} + +describe("Proxy API key auth", () => { + beforeEach(() => { + queryCallCount = 0 + }) + + it("allows requests when API key auth is disabled", async () => { + const app = createTestApp() + + const response = await post(app, { "x-api-key": "dummy" }) + expect(response.status).toBe(200) + expect(queryCallCount).toBe(1) + }) + + it("allows requests with a configured x-api-key", async () => { + const app = createTestApp({ requiredApiKeys: ["alpha", "beta"] }) + + const response = await post(app, { "x-api-key": "beta" }) + expect(response.status).toBe(200) + expect(queryCallCount).toBe(1) + }) + + it("allows requests with a bearer token", async () => { + const app = createTestApp({ requiredApiKeys: ["alpha", "beta"] }) + + const response = await post(app, { authorization: "Bearer alpha" }) + expect(response.status).toBe(200) + expect(queryCallCount).toBe(1) + }) + + it("rejects requests with no key when auth is enabled", async () => { + const app = createTestApp({ requiredApiKeys: ["alpha"] }) + + const response = await post(app) + const body = await response.json() as any + + expect(response.status).toBe(401) + expect(body.error.type).toBe("authentication_error") + expect(queryCallCount).toBe(0) + }) + + it("rejects requests with the wrong key", async () => { + const app = createTestApp({ requiredApiKeys: ["alpha"] }) + + const response = await post(app, { "x-api-key": "wrong" }) + const body = await response.json() as any + + expect(response.status).toBe(401) + expect(body.error.type).toBe("authentication_error") + expect(queryCallCount).toBe(0) + }) +}) diff --git a/src/proxy/auth.ts b/src/proxy/auth.ts new file mode 100644 index 0000000..e3c5005 --- /dev/null +++ b/src/proxy/auth.ts @@ -0,0 +1,22 @@ +export function normalizeRequiredApiKeys(keys?: string[]): string[] { + return (keys ?? []).map((key) => key.trim()).filter(Boolean) +} + +export function extractRequestApiKey(xApiKey?: string, authorization?: string): string | undefined { + if (xApiKey?.trim()) return xApiKey.trim() + + const bearerMatch = authorization?.match(/^Bearer\s+(.+)$/i) + const bearerToken = bearerMatch?.[1]?.trim() + return bearerToken || undefined +} + +export function isApiKeyAuthEnabled(requiredApiKeys?: string[]): boolean { + return normalizeRequiredApiKeys(requiredApiKeys).length > 0 +} + +export function isApiKeyAuthorized(providedApiKey: string | undefined, requiredApiKeys?: string[]): boolean { + const normalizedKeys = normalizeRequiredApiKeys(requiredApiKeys) + if (normalizedKeys.length === 0) return true + if (!providedApiKey) return false + return normalizedKeys.includes(providedApiKey) +} diff --git a/src/proxy/configLoader.ts b/src/proxy/configLoader.ts new file mode 100644 index 0000000..736227b --- /dev/null +++ b/src/proxy/configLoader.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { isAbsolute, join } from "node:path" +import type { ProfileConfig, ProxyConfig } from "./types" + +function expandHome(filePath: string, homeDir: string): string { + return filePath === "~" ? homeDir : filePath.startsWith("~/") ? join(homeDir, filePath.slice(2)) : filePath +} + +function resolveEnvReference(value: string): string | undefined { + const match = value.match(/^(?:\$env:|env:)(.+)$/) + if (!match) return undefined + return process.env[match[1]?.trim() ?? ""] +} + +function resolveConfigString(value: string, homeDir: string): string | undefined { + const envValue = resolveEnvReference(value) + const resolved = envValue ?? value + return expandHome(resolved, homeDir) +} + +function normalizeProfile(profile: ProfileConfig, homeDir: string): ProfileConfig { + return { + ...profile, + claudeConfigDir: profile.claudeConfigDir ? resolveConfigString(profile.claudeConfigDir, homeDir) : undefined, + claudeExecutable: profile.claudeExecutable ? resolveConfigString(profile.claudeExecutable, homeDir) : undefined, + apiKey: profile.apiKey ? resolveConfigString(profile.apiKey, homeDir) : undefined, + apiKeyEnv: profile.apiKeyEnv, + baseUrl: profile.baseUrl ? resolveConfigString(profile.baseUrl, homeDir) : undefined, + authToken: profile.authToken ? resolveConfigString(profile.authToken, homeDir) : undefined, + authTokenEnv: profile.authTokenEnv, + } +} + +function normalizeConfig(config: Partial, homeDir: string): Partial { + return { + ...config, + requiredApiKeys: config.requiredApiKeys + ?.map((key) => resolveConfigString(key, homeDir)) + .filter((key): key is string => Boolean(key)), + profiles: config.profiles?.map((profile) => normalizeProfile(profile, homeDir)), + } +} + +function mergeConfigs(base: Partial, override: Partial): Partial { + return { + ...base, + ...Object.fromEntries(Object.entries(override).filter(([, value]) => value !== undefined)), + } +} + +function getDefaultConfigPaths(cwd: string, homeDir: string): string[] { + return [ + join(homeDir, ".config", "meridian", "config.json"), + join(cwd, "meridian.config.json"), + ] +} + +export interface ConfigLoaderOptions { + cwd?: string + homeDir?: string + configPath?: string +} + +export function loadProxyConfigFile(options: ConfigLoaderOptions = {}): Partial { + const cwd = options.cwd ?? process.cwd() + const homeDir = options.homeDir ?? homedir() + const explicitConfigPath = options.configPath ?? process.env.CLAUDE_PROXY_CONFIG + const configPaths = explicitConfigPath + ? [expandHome(explicitConfigPath, homeDir)] + : getDefaultConfigPaths(cwd, homeDir) + + if (explicitConfigPath) { + const filePath = isAbsolute(configPaths[0]!) ? configPaths[0]! : join(cwd, configPaths[0]!) + if (!existsSync(filePath)) { + throw new Error(`Config file not found: ${filePath}`) + } + } + + return configPaths.reduce>((merged, candidatePath) => { + const filePath = isAbsolute(candidatePath) ? candidatePath : join(cwd, candidatePath) + if (!existsSync(filePath)) return merged + + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Partial + const normalized = normalizeConfig(parsed, homeDir) + return mergeConfigs(merged, normalized) + }, {}) +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 5c718e3..ab3c245 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -25,6 +25,7 @@ import { ALLOWED_MCP_TOOLS } from "./tools" import { getLastUserMessage } from "./messages" import { openCodeAdapter } from "./adapters/opencode" import { buildQueryOptions, type QueryContext } from "./query" +import { extractRequestApiKey, isApiKeyAuthEnabled, isApiKeyAuthorized } from "./auth" import { resolveProfile } from "./profiles" import { computeLineageHash, @@ -133,6 +134,38 @@ export function createProxyServer(config: Partial = {}): ProxyServe app.use("*", cors()) + const createApiKeyMiddleware = (requiredKeys?: string[]) => async (c: Context, next: () => Promise) => { + if (!isApiKeyAuthEnabled(requiredKeys)) { + await next() + return + } + + const providedApiKey = extractRequestApiKey( + c.req.header("x-api-key"), + c.req.header("authorization"), + ) + + if (!isApiKeyAuthorized(providedApiKey, requiredKeys)) { + return c.json( + { + type: "error", + error: { + type: "authentication_error", + message: "Invalid or missing API key", + }, + }, + 401, + ) + } + + await next() + } + + const validateApiKey = createApiKeyMiddleware(finalConfig.requiredApiKeys) + + app.use("/v1/messages", validateApiKey) + app.use("/messages", validateApiKey) + app.get("/", (c) => { // API clients get JSON, browsers get the landing page const accept = c.req.header("accept") || "" diff --git a/src/proxy/types.ts b/src/proxy/types.ts index 52da5c7..5829dc0 100644 --- a/src/proxy/types.ts +++ b/src/proxy/types.ts @@ -6,10 +6,20 @@ export interface ProxyConfig { debug: boolean idleTimeoutSeconds: number silent: boolean + requiredApiKeys?: string[] profiles?: ProfileConfig[] defaultProfile?: string } +function parseRequiredApiKeys(envValue: string | undefined): string[] | undefined { + const keys = envValue + ?.split(",") + .map((key) => key.trim()) + .filter(Boolean) + + return keys && keys.length > 0 ? keys : undefined +} + export type ProfileType = "claude-max" | "api" export interface ProfileConfig { @@ -47,6 +57,7 @@ export const DEFAULT_PROXY_CONFIG: ProxyConfig = { debug: process.env.CLAUDE_PROXY_DEBUG === "1", idleTimeoutSeconds: 120, silent: false, + requiredApiKeys: parseRequiredApiKeys(process.env.CLAUDE_PROXY_API_KEYS), profiles: undefined, defaultProfile: undefined, }