mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(provider): restore desktop OpenAI proxy auth #1025
This commit is contained in:
parent
254e5fcfb1
commit
d048a3b3c4
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<string, string>
|
||||
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<string, string>
|
||||
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<string, unknown> }> = []
|
||||
const calls: Array<{ body: Record<string, unknown>; headers: Headers }> = []
|
||||
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
|
||||
calls.push({
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, string> | undefined,
|
||||
): Record<string, string> {
|
||||
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<string, string> | undefined,
|
||||
): Record<string, string> {
|
||||
return withoutCcdSpawnEnvKeys(
|
||||
withoutHostManagedProviderVars(withoutSSHTunnelVars(env)),
|
||||
withoutHostOwnedEnvVars(
|
||||
withoutHostManagedProviderVars(withoutSSHTunnelVars(env)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
32
src/utils/subprocessEnv.test.ts
Normal file
32
src/utils/subprocessEnv.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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_<NAME> for `with:` inputs, duplicating
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user