diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 5a246ae6..dd097b0a 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -21,6 +21,7 @@ describe('ConversationService', () => { let originalEntrypoint: string | undefined let originalOAuthToken: string | undefined let originalProviderManagedByHost: string | undefined + let originalLocalAccessToken: string | undefined let originalDiagnosticsFile: string | undefined let originalAttributionHeader: string | undefined let originalDisableExperimentalBetas: string | undefined @@ -45,6 +46,7 @@ describe('ConversationService', () => { originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER originalDisableExperimentalBetas = process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS @@ -69,6 +71,7 @@ describe('ConversationService', () => { // buildChildEnv injects it or not without interference from the shell env. delete process.env.CLAUDE_CODE_ENTRYPOINT delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS @@ -106,6 +109,9 @@ describe('ConversationService', () => { if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost + if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken + if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile @@ -458,6 +464,7 @@ describe('ConversationService', () => { }) test('buildChildEnv injects explicit provider runtime env for session-scoped providers', async () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' const providerService = new ProviderService() const provider = await providerService.addProvider({ presetId: 'custom', @@ -485,6 +492,7 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6') expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2.6') expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe('desktop-local-secret') expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CC_HAHA_TRANSCRIPT_ENTRYPOINT).toBe('claude-desktop') expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 1a1c8a4c..f23109b3 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -26,6 +26,7 @@ const originalShell = process.env.SHELL const originalZdotdir = process.env.ZDOTDIR const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS +const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN const isWindows = process.platform === 'win32' const unixOnly = isWindows ? it.skip : it @@ -114,6 +115,11 @@ function restoreEnv(): void { } else { delete process.env.CC_HAHA_TASK_TIMEOUT_MS } + if (originalLocalAccessToken !== undefined) { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken + } else { + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + } resetTerminalShellEnvironmentCacheForTests() } @@ -352,6 +358,7 @@ describe('cron scheduler launcher resolution', () => { process.env.ANTHROPIC_BASE_URL = 'https://stale-parent.example' process.env.ANTHROPIC_MODEL = 'stale-parent-model' process.env.CLAUDE_CODE_ENTRYPOINT = 'stale-parent-entrypoint' + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' const provider = await new ProviderService().addProvider({ presetId: 'custom', @@ -405,6 +412,7 @@ describe('cron scheduler launcher resolution', () => { expect(env.ANTHROPIC_MODEL).toBe('provider-fast') expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model') expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe('desktop-local-secret') expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0') expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 23b0bc85..074e8d59 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -311,6 +311,43 @@ describe('remote H5 auth and CORS integration', () => { await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' }) }) + test('keeps the host-managed provider proxy working with local auth across H5 modes', async () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + await restartRemoteServer() + + const requestProxy = (authorized: boolean) => fetch(`${baseUrl}/proxy/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'proxy-managed', + ...(authorized + ? { Authorization: 'Bearer desktop-local-secret' } + : {}), + }, + body: JSON.stringify({ model: 'test', max_tokens: 8, messages: [] }), + }) + + const disabledTokenlessResponse = await requestProxy(false) + expect(disabledTokenlessResponse.status).toBe(403) + + const disabledAuthorizedResponse = await requestProxy(true) + expect(disabledAuthorizedResponse.status).toBe(400) + await expect(disabledAuthorizedResponse.json()).resolves.toMatchObject({ + error: { message: 'No active provider configured for proxy' }, + }) + + await new H5AccessService().enable() + + const enabledTokenlessResponse = await requestProxy(false) + expect(enabledTokenlessResponse.status).toBe(401) + + const enabledAuthorizedResponse = await requestProxy(true) + expect(enabledAuthorizedResponse.status).toBe(400) + await expect(enabledAuthorizedResponse.json()).resolves.toMatchObject({ + error: { message: 'No active provider configured for proxy' }, + }) + }) + test('does not keep retired Tauri origins trusted after Electron replacement', async () => { const response = await fetch(`${baseUrl}/api/status`, { headers: { diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 5e3d26ea..3492a7c5 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -1068,20 +1068,34 @@ describe('ProviderService', () => { expect(dummyRuntimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('dummy') }) - test('proxy providers keep proxy-managed auth regardless of auth strategy', async () => { - const svc = new ProviderService() - const provider = await svc.addProvider(sampleInput({ - apiFormat: 'openai_chat', - authStrategy: 'auth_token', - })) + test('proxy providers keep transient desktop auth out of persisted settings', async () => { + const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' - await svc.activateProvider(provider.id) + try { + const svc = new ProviderService() + for (const apiFormat of ['openai_chat', 'openai_responses'] as const) { + const provider = await svc.addProvider(sampleInput({ + apiFormat, + authStrategy: 'auth_token', + })) - const settings = await readSettings() - const env = settings.env as Record - expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed') - expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() - expect(env.ENABLE_TOOL_SEARCH).toBeUndefined() + await svc.activateProvider(provider.id) + + const settings = await readSettings() + const env = settings.env as Record + expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed') + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env.ENABLE_TOOL_SEARCH).toBeUndefined() + expect(JSON.stringify(settings)).not.toContain('desktop-local-secret') + } + } finally { + if (originalLocalAccessToken === undefined) { + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + } else { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken + } + } }) test('should include preset default env on activation and runtime env', async () => { @@ -1302,7 +1316,9 @@ describe('ProviderService', () => { describe('handleProxyRequest', () => { test('records a session trace for proxied OpenAI Chat calls', async () => { const originalFetch = globalThis.fetch - globalThis.fetch = mock(async () => { + const upstreamHeaders: Headers[] = [] + globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => { + upstreamHeaders.push(new Headers(init?.headers)) return new Response(JSON.stringify({ id: 'chatcmpl-trace', object: 'chat.completion', @@ -1325,6 +1341,7 @@ describe('ProviderService', () => { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: 'Bearer desktop-local-secret', 'X-Claude-Code-Session-Id': 'session-proxy-trace', }, body: JSON.stringify({ @@ -1350,6 +1367,8 @@ describe('ProviderService', () => { }) expect(trace.calls[0].request.body.preview).toContain('capture this call') expect(trace.calls[0].response.body.preview).toContain('chatcmpl-trace') + expect(upstreamHeaders[0].get('Authorization')).toBe('Bearer sk-test-key-123') + expect(upstreamHeaders[0].get('Authorization')).not.toContain('desktop-local-secret') } finally { globalThis.fetch = originalFetch } @@ -1408,9 +1427,12 @@ describe('ProviderService', () => { test('forwards a stable prompt_cache_key from client session metadata for OpenAI Responses upstreams', async () => { const originalFetch = globalThis.fetch - const calls: Array<{ body: Record }> = [] + const calls: Array<{ body: Record; headers: Headers }> = [] globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { - calls.push({ body: JSON.parse(String(init?.body)) as Record }) + calls.push({ + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }) return new Response(JSON.stringify({ id: 'resp-1', object: 'response', @@ -1432,7 +1454,10 @@ describe('ProviderService', () => { const req = new Request('http://localhost:3456/proxy/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer desktop-local-secret', + }, body: JSON.stringify({ model: 'gpt-5.4', max_tokens: 64, @@ -1444,6 +1469,8 @@ describe('ProviderService', () => { const res = await handleProxyRequest(req, new URL(req.url)) expect(res.status).toBe(200) expect(calls[0].body.prompt_cache_key).toBe('sess-42aa') + expect(calls[0].headers.get('Authorization')).toBe('Bearer sk-test-key-123') + expect(calls[0].headers.get('Authorization')).not.toContain('desktop-local-secret') } finally { globalThis.fetch = originalFetch } diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 1f79c8b9..5f14cee1 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -56,6 +56,52 @@ describe('resolveAnthropicClientApiKey', () => { }) }) +describe('resolveManagedProviderProxyAccessToken', () => { + test('returns the desktop local credential only for the host-managed loopback proxy', async () => { + const { resolveManagedProviderProxyAccessToken } = await import('./client.js') + const input = { + providerManagedByHost: '1', + apiKey: 'proxy-managed', + baseUrl: 'http://127.0.0.1:3456/proxy/providers/provider-1', + localAccessToken: ' desktop-local-secret ', + } + + expect(resolveManagedProviderProxyAccessToken(input)).toBe('desktop-local-secret') + expect(resolveManagedProviderProxyAccessToken({ + ...input, + baseUrl: 'http://127.0.0.1:3456/proxy', + })).toBe('desktop-local-secret') + expect(resolveManagedProviderProxyAccessToken({ + ...input, + requestUrl: 'http://127.0.0.1:3456/proxy/providers/provider-1/v1/messages', + })).toBe('desktop-local-secret') + }) + + test('never sends the desktop credential to unowned or direct provider URLs', async () => { + const { resolveManagedProviderProxyAccessToken } = await import('./client.js') + const input = { + providerManagedByHost: '1', + apiKey: 'proxy-managed', + baseUrl: 'http://127.0.0.1:3456/proxy/providers/provider-1', + localAccessToken: 'desktop-local-secret', + } + + for (const override of [ + { providerManagedByHost: undefined }, + { apiKey: 'real-provider-key' }, + { baseUrl: 'https://api.example.com/proxy/providers/provider-1' }, + { baseUrl: 'http://localhost:3456/proxy/providers/provider-1' }, + { baseUrl: 'http://127.0.0.1:3456/api/status' }, + { baseUrl: 'http://127.0.0.1:3456/proxy/providers/provider-1?target=external' }, + { requestUrl: 'http://127.0.0.1:3457/proxy/providers/provider-1/v1/messages' }, + { requestUrl: 'http://127.0.0.1:3456/api/status' }, + { localAccessToken: undefined }, + ]) { + expect(resolveManagedProviderProxyAccessToken({ ...input, ...override })).toBeNull() + } + }) +}) + describe('shouldUseOpenAICodexTransport', () => { test('lets ChatGPT Official marker override a saved Claude subscriber login', async () => { const { shouldUseOpenAICodexTransport } = await import('./client.js') @@ -152,6 +198,67 @@ describe('getAnthropicClient', () => { } }) + test('authenticates the host-managed desktop provider proxy with the local access token', async () => { + const { getAnthropicClient } = await import('./client.js') + let requestHeaders: Headers | null = null + const previous = { + authToken: process.env.ANTHROPIC_AUTH_TOKEN, + apiKey: process.env.ANTHROPIC_API_KEY, + baseUrl: process.env.ANTHROPIC_BASE_URL, + localAccessToken: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, + providerManagedByHost: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, + simple: process.env.CLAUDE_CODE_SIMPLE, + } + + process.env.ANTHROPIC_AUTH_TOKEN = 'stale-provider-token' + process.env.ANTHROPIC_API_KEY = 'proxy-managed' + process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:3456/proxy/providers/provider-1' + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1' + process.env.CLAUDE_CODE_SIMPLE = '1' + + try { + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'openai-compatible-model', + fetchOverride: async (_input, init) => { + requestHeaders = new Headers(init?.headers) + return Response.json({ + id: 'msg-local-proxy-auth', + type: 'message', + role: 'assistant', + model: 'openai-compatible-model', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + }, + }) + + expect(client.apiKey).toBe('proxy-managed') + await client.messages.create({ + model: 'openai-compatible-model', + max_tokens: 16, + messages: [{ role: 'user', content: 'hello' }], + }) + expect(requestHeaders?.get('x-api-key')).toBe('proxy-managed') + expect(requestHeaders?.get('Authorization')).toBe('Bearer desktop-local-secret') + } finally { + for (const [envKey, value] of [ + ['ANTHROPIC_AUTH_TOKEN', previous.authToken], + ['ANTHROPIC_API_KEY', previous.apiKey], + ['ANTHROPIC_BASE_URL', previous.baseUrl], + ['CC_HAHA_LOCAL_ACCESS_TOKEN', previous.localAccessToken], + ['CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', previous.providerManagedByHost], + ['CLAUDE_CODE_SIMPLE', previous.simple], + ] as const) { + if (value === undefined) delete process.env[envKey] + else process.env[envKey] = value + } + } + }) + test('bypasses system proxy for local desktop provider proxy base URLs', async () => { const { getAnthropicClient } = await import('./client.js') const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN diff --git a/src/services/api/client.ts b/src/services/api/client.ts index f437bef4..0a6d3e0b 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -115,6 +115,63 @@ export function resolveAnthropicClientApiKey({ return explicitApiKey || getFallbackApiKey() } +export function resolveManagedProviderProxyAccessToken({ + providerManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, + apiKey = process.env.ANTHROPIC_API_KEY, + baseUrl = process.env.ANTHROPIC_BASE_URL, + localAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, + requestUrl = baseUrl, +}: { + providerManagedByHost?: string + apiKey?: string + baseUrl?: string + localAccessToken?: string + requestUrl?: string +} = {}): string | null { + const token = localAccessToken?.trim() + if ( + !isEnvTruthy(providerManagedByHost) || + apiKey !== 'proxy-managed' || + !baseUrl || + !token + ) { + return null + } + + try { + const base = new URL(baseUrl) + const request = new URL(requestUrl || baseUrl) + const basePathname = base.pathname.replace(/\/+$/, '') || '/' + const requestPathname = request.pathname.replace(/\/+$/, '') || '/' + const isManagedProxyPath = basePathname === '/proxy' || + /^\/proxy\/providers\/[^/]+$/.test(basePathname) + const isRequestWithinProxy = request.origin === base.origin && + ( + requestPathname === basePathname || + requestPathname.startsWith(`${basePathname}/`) + ) + + if ( + base.protocol !== 'http:' || + base.hostname !== '127.0.0.1' || + base.username || + base.password || + base.search || + base.hash || + request.username || + request.password || + !isManagedProxyPath || + !isRequestWithinProxy + ) { + return null + } + } catch { + return null + } + + return token +} + export function shouldUseOpenAICodexTransport({ hasOpenAIAuth, isClaudeSubscriber, @@ -456,6 +513,15 @@ function buildFetch( return (input, init) => { // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins const headers = new Headers(init?.headers) + // Authenticate only the exact host-managed loopback proxy request. Doing + // this at the final fetch seam prevents request-level headers from replacing + // the desktop credential and prevents that credential from reaching the + // real provider; the server adds the upstream provider key itself. + const requestUrl = input instanceof Request ? input.url : String(input) + const localProxyToken = resolveManagedProviderProxyAccessToken({ requestUrl }) + if (localProxyToken) { + headers.set('Authorization', `Bearer ${localProxyToken}`) + } // Generate a client-side request ID so timeouts (which return no server // request ID) can still be correlated with server logs by the API team. // Callers that want to track the ID themselves can pre-set the header. @@ -463,11 +529,9 @@ function buildFetch( headers.set(CLIENT_REQUEST_ID_HEADER, randomUUID()) } try { - // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins - const url = input instanceof Request ? input.url : String(input) const id = headers.get(CLIENT_REQUEST_ID_HEADER) logForDebugging( - `[API REQUEST] ${new URL(url).pathname}${id ? ` ${CLIENT_REQUEST_ID_HEADER}=${id}` : ''} source=${source ?? 'unknown'}`, + `[API REQUEST] ${new URL(requestUrl).pathname}${id ? ` ${CLIENT_REQUEST_ID_HEADER}=${id}` : ''} source=${source ?? 'unknown'}`, ) } catch { // never let logging crash the fetch diff --git a/src/utils/managedEnv.test.ts b/src/utils/managedEnv.test.ts index 2cf5bfbf..fdbda365 100644 --- a/src/utils/managedEnv.test.ts +++ b/src/utils/managedEnv.test.ts @@ -9,6 +9,7 @@ let tmpDir: string const originalEnv = { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, + CC_HAHA_LOCAL_ACCESS_TOKEN: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, @@ -34,6 +35,7 @@ describe('managedEnv', () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-')) process.env.CLAUDE_CONFIG_DIR = tmpDir delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN delete process.env.ANTHROPIC_BASE_URL delete process.env.ANTHROPIC_API_KEY delete process.env.ANTHROPIC_AUTH_TOKEN @@ -47,6 +49,7 @@ describe('managedEnv', () => { await fs.rm(tmpDir, { recursive: true, force: true }) restoreEnv('CLAUDE_CONFIG_DIR') restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST') + restoreEnv('CC_HAHA_LOCAL_ACCESS_TOKEN') restoreEnv('ANTHROPIC_BASE_URL') restoreEnv('ANTHROPIC_API_KEY') restoreEnv('ANTHROPIC_AUTH_TOKEN') @@ -85,4 +88,20 @@ describe('managedEnv', () => { const health = await fetch(new URL('/health', baseUrl.origin)) expect(health.status).toBe(200) }) + + test('does not let settings replace host-owned provider routing credentials', async () => { + await writeJson(path.join(tmpDir, 'cc-haha', 'settings.json'), { + env: { + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '0', + CC_HAHA_LOCAL_ACCESS_TOKEN: 'stale-settings-token', + }, + }) + process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1' + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + + applySafeConfigEnvironmentVariables() + + expect(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') + expect(process.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe('desktop-local-secret') + }) }) diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index 1e610cfc..eaa82b21 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -66,6 +66,23 @@ function withoutHostManagedProviderVars( return out } +const HOST_OWNED_ENV_KEYS = new Set([ + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CC_HAHA_LOCAL_ACCESS_TOKEN', +]) + +function withoutHostOwnedEnvVars( + env: Record | undefined, +): Record { + if (!env || !isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST)) { + return env || {} + } + + return Object.fromEntries( + Object.entries(env).filter(([key]) => !HOST_OWNED_ENV_KEYS.has(key)), + ) +} + /** * Snapshot of env keys present before any settings.env is applied — for CCD, * these are the keys the desktop host set to orchestrate the subprocess. @@ -94,7 +111,9 @@ function filterSettingsEnv( env: Record | undefined, ): Record { return withoutCcdSpawnEnvKeys( - withoutHostManagedProviderVars(withoutSSHTunnelVars(env)), + withoutHostOwnedEnvVars( + withoutHostManagedProviderVars(withoutSSHTunnelVars(env)), + ), ) } diff --git a/src/utils/subprocessEnv.test.ts b/src/utils/subprocessEnv.test.ts new file mode 100644 index 00000000..f06e486e --- /dev/null +++ b/src/utils/subprocessEnv.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { subprocessEnv } from './subprocessEnv.js' + +const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN +const originalScrubFlag = process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB + +afterEach(() => { + if (originalLocalAccessToken === undefined) { + delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + } else { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken + } + + if (originalScrubFlag === undefined) { + delete process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB + } else { + process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = originalScrubFlag + } +}) + +describe('subprocessEnv', () => { + test('never exposes the desktop local access token to tool subprocesses', () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret' + delete process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB + + const env = subprocessEnv() + + expect(env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBeUndefined() + expect(process.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe('desktop-local-secret') + }) +}) diff --git a/src/utils/subprocessEnv.ts b/src/utils/subprocessEnv.ts index 4eb62c99..0cfda4b0 100644 --- a/src/utils/subprocessEnv.ts +++ b/src/utils/subprocessEnv.ts @@ -52,6 +52,12 @@ const GHA_SUBPROCESS_SCRUB = [ 'SSH_SIGNING_KEY', ] as const +const ALWAYS_SUBPROCESS_SCRUB = [ + // Used only by the Claude process to authenticate its host-managed API hop. + // Bash, hooks, MCP, LSP, and shell snapshots must never inherit it. + 'CC_HAHA_LOCAL_ACCESS_TOKEN', +] as const + /** * Returns a copy of process.env with sensitive secrets stripped, for use when * spawning subprocesses (Bash tool, shell snapshot, MCP stdio servers, LSP @@ -82,13 +88,28 @@ export function subprocessEnv(): NodeJS.ProcessEnv { // proxy is disabled or not registered (non-CCR), so this is a no-op outside // CCR containers. const proxyEnv = _getUpstreamProxyEnv?.() ?? {} + const shouldScrubGhaSecrets = isEnvTruthy( + process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB, + ) - if (!isEnvTruthy(process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB)) { + if ( + !shouldScrubGhaSecrets && + !ALWAYS_SUBPROCESS_SCRUB.some((key) => process.env[key] !== undefined) + ) { return Object.keys(proxyEnv).length > 0 ? { ...process.env, ...proxyEnv } : process.env } + const env = { ...process.env, ...proxyEnv } + for (const key of ALWAYS_SUBPROCESS_SCRUB) { + delete env[key] + } + + if (!shouldScrubGhaSecrets) { + return env + } + for (const k of GHA_SUBPROCESS_SCRUB) { delete env[k] // GitHub Actions auto-creates INPUT_ for `with:` inputs, duplicating