mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23: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 originalEntrypoint: string | undefined
|
||||||
let originalOAuthToken: string | undefined
|
let originalOAuthToken: string | undefined
|
||||||
let originalProviderManagedByHost: string | undefined
|
let originalProviderManagedByHost: string | undefined
|
||||||
|
let originalLocalAccessToken: string | undefined
|
||||||
let originalDiagnosticsFile: string | undefined
|
let originalDiagnosticsFile: string | undefined
|
||||||
let originalAttributionHeader: string | undefined
|
let originalAttributionHeader: string | undefined
|
||||||
let originalDisableExperimentalBetas: string | undefined
|
let originalDisableExperimentalBetas: string | undefined
|
||||||
@ -45,6 +46,7 @@ describe('ConversationService', () => {
|
|||||||
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||||
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
||||||
|
originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||||
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
|
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
|
||||||
originalDisableExperimentalBetas = process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
|
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.
|
// buildChildEnv injects it or not without interference from the shell env.
|
||||||
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
delete process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
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_DIAGNOSTICS_FILE
|
||||||
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
|
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
|
||||||
delete process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS
|
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
|
if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
||||||
else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost
|
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
|
if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
|
||||||
else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile
|
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 () => {
|
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 providerService = new ProviderService()
|
||||||
const provider = await providerService.addProvider({
|
const provider = await providerService.addProvider({
|
||||||
presetId: 'custom',
|
presetId: 'custom',
|
||||||
@ -485,6 +492,7 @@ describe('ConversationService', () => {
|
|||||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6')
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2.6')
|
||||||
expect(env.ANTHROPIC_DEFAULT_OPUS_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.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_ATTRIBUTION_HEADER).toBe('0')
|
||||||
expect(env.CC_HAHA_TRANSCRIPT_ENTRYPOINT).toBe('claude-desktop')
|
expect(env.CC_HAHA_TRANSCRIPT_ENTRYPOINT).toBe('claude-desktop')
|
||||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||||
|
|||||||
@ -26,6 +26,7 @@ const originalShell = process.env.SHELL
|
|||||||
const originalZdotdir = process.env.ZDOTDIR
|
const originalZdotdir = process.env.ZDOTDIR
|
||||||
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
|
||||||
const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS
|
const originalTaskTimeout = process.env.CC_HAHA_TASK_TIMEOUT_MS
|
||||||
|
const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
const unixOnly = isWindows ? it.skip : it
|
const unixOnly = isWindows ? it.skip : it
|
||||||
@ -114,6 +115,11 @@ function restoreEnv(): void {
|
|||||||
} else {
|
} else {
|
||||||
delete process.env.CC_HAHA_TASK_TIMEOUT_MS
|
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()
|
resetTerminalShellEnvironmentCacheForTests()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,6 +358,7 @@ describe('cron scheduler launcher resolution', () => {
|
|||||||
process.env.ANTHROPIC_BASE_URL = 'https://stale-parent.example'
|
process.env.ANTHROPIC_BASE_URL = 'https://stale-parent.example'
|
||||||
process.env.ANTHROPIC_MODEL = 'stale-parent-model'
|
process.env.ANTHROPIC_MODEL = 'stale-parent-model'
|
||||||
process.env.CLAUDE_CODE_ENTRYPOINT = 'stale-parent-entrypoint'
|
process.env.CLAUDE_CODE_ENTRYPOINT = 'stale-parent-entrypoint'
|
||||||
|
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
|
||||||
|
|
||||||
const provider = await new ProviderService().addProvider({
|
const provider = await new ProviderService().addProvider({
|
||||||
presetId: 'custom',
|
presetId: 'custom',
|
||||||
@ -405,6 +412,7 @@ describe('cron scheduler launcher resolution', () => {
|
|||||||
expect(env.ANTHROPIC_MODEL).toBe('provider-fast')
|
expect(env.ANTHROPIC_MODEL).toBe('provider-fast')
|
||||||
expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model')
|
expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model')
|
||||||
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
|
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_ATTRIBUTION_HEADER).toBe('0')
|
||||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
|
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' })
|
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 () => {
|
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/status`, {
|
const response = await fetch(`${baseUrl}/api/status`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -1068,20 +1068,34 @@ describe('ProviderService', () => {
|
|||||||
expect(dummyRuntimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('dummy')
|
expect(dummyRuntimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('dummy')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('proxy providers keep proxy-managed auth regardless of auth strategy', async () => {
|
test('proxy providers keep transient desktop auth out of persisted settings', async () => {
|
||||||
const svc = new ProviderService()
|
const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
|
||||||
const provider = await svc.addProvider(sampleInput({
|
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
|
||||||
apiFormat: 'openai_chat',
|
|
||||||
authStrategy: 'auth_token',
|
|
||||||
}))
|
|
||||||
|
|
||||||
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()
|
await svc.activateProvider(provider.id)
|
||||||
const env = settings.env as Record<string, string>
|
|
||||||
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
|
const settings = await readSettings()
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
const env = settings.env as Record<string, string>
|
||||||
expect(env.ENABLE_TOOL_SEARCH).toBeUndefined()
|
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 () => {
|
test('should include preset default env on activation and runtime env', async () => {
|
||||||
@ -1302,7 +1316,9 @@ describe('ProviderService', () => {
|
|||||||
describe('handleProxyRequest', () => {
|
describe('handleProxyRequest', () => {
|
||||||
test('records a session trace for proxied OpenAI Chat calls', async () => {
|
test('records a session trace for proxied OpenAI Chat calls', async () => {
|
||||||
const originalFetch = globalThis.fetch
|
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({
|
return new Response(JSON.stringify({
|
||||||
id: 'chatcmpl-trace',
|
id: 'chatcmpl-trace',
|
||||||
object: 'chat.completion',
|
object: 'chat.completion',
|
||||||
@ -1325,6 +1341,7 @@ describe('ProviderService', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer desktop-local-secret',
|
||||||
'X-Claude-Code-Session-Id': 'session-proxy-trace',
|
'X-Claude-Code-Session-Id': 'session-proxy-trace',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -1350,6 +1367,8 @@ describe('ProviderService', () => {
|
|||||||
})
|
})
|
||||||
expect(trace.calls[0].request.body.preview).toContain('capture this call')
|
expect(trace.calls[0].request.body.preview).toContain('capture this call')
|
||||||
expect(trace.calls[0].response.body.preview).toContain('chatcmpl-trace')
|
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 {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
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 () => {
|
test('forwards a stable prompt_cache_key from client session metadata for OpenAI Responses upstreams', async () => {
|
||||||
const originalFetch = globalThis.fetch
|
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) => {
|
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({
|
return new Response(JSON.stringify({
|
||||||
id: 'resp-1',
|
id: 'resp-1',
|
||||||
object: 'response',
|
object: 'response',
|
||||||
@ -1432,7 +1454,10 @@ describe('ProviderService', () => {
|
|||||||
|
|
||||||
const req = new Request('http://localhost:3456/proxy/v1/messages', {
|
const req = new Request('http://localhost:3456/proxy/v1/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer desktop-local-secret',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
max_tokens: 64,
|
max_tokens: 64,
|
||||||
@ -1444,6 +1469,8 @@ describe('ProviderService', () => {
|
|||||||
const res = await handleProxyRequest(req, new URL(req.url))
|
const res = await handleProxyRequest(req, new URL(req.url))
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(calls[0].body.prompt_cache_key).toBe('sess-42aa')
|
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 {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
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', () => {
|
describe('shouldUseOpenAICodexTransport', () => {
|
||||||
test('lets ChatGPT Official marker override a saved Claude subscriber login', async () => {
|
test('lets ChatGPT Official marker override a saved Claude subscriber login', async () => {
|
||||||
const { shouldUseOpenAICodexTransport } = await import('./client.js')
|
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 () => {
|
test('bypasses system proxy for local desktop provider proxy base URLs', async () => {
|
||||||
const { getAnthropicClient } = await import('./client.js')
|
const { getAnthropicClient } = await import('./client.js')
|
||||||
const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
|
const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
|
||||||
|
|||||||
@ -115,6 +115,63 @@ export function resolveAnthropicClientApiKey({
|
|||||||
return explicitApiKey || getFallbackApiKey()
|
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({
|
export function shouldUseOpenAICodexTransport({
|
||||||
hasOpenAIAuth,
|
hasOpenAIAuth,
|
||||||
isClaudeSubscriber,
|
isClaudeSubscriber,
|
||||||
@ -456,6 +513,15 @@ function buildFetch(
|
|||||||
return (input, init) => {
|
return (input, init) => {
|
||||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||||
const headers = new Headers(init?.headers)
|
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
|
// 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.
|
// 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.
|
// 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())
|
headers.set(CLIENT_REQUEST_ID_HEADER, randomUUID())
|
||||||
}
|
}
|
||||||
try {
|
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)
|
const id = headers.get(CLIENT_REQUEST_ID_HEADER)
|
||||||
logForDebugging(
|
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 {
|
} catch {
|
||||||
// never let logging crash the fetch
|
// never let logging crash the fetch
|
||||||
|
|||||||
@ -9,6 +9,7 @@ let tmpDir: string
|
|||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||||
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST,
|
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_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
||||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||||
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||||
@ -34,6 +35,7 @@ describe('managedEnv', () => {
|
|||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-'))
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
|
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_BASE_URL
|
||||||
delete process.env.ANTHROPIC_API_KEY
|
delete process.env.ANTHROPIC_API_KEY
|
||||||
delete process.env.ANTHROPIC_AUTH_TOKEN
|
delete process.env.ANTHROPIC_AUTH_TOKEN
|
||||||
@ -47,6 +49,7 @@ describe('managedEnv', () => {
|
|||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
restoreEnv('CLAUDE_CONFIG_DIR')
|
restoreEnv('CLAUDE_CONFIG_DIR')
|
||||||
restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST')
|
restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST')
|
||||||
|
restoreEnv('CC_HAHA_LOCAL_ACCESS_TOKEN')
|
||||||
restoreEnv('ANTHROPIC_BASE_URL')
|
restoreEnv('ANTHROPIC_BASE_URL')
|
||||||
restoreEnv('ANTHROPIC_API_KEY')
|
restoreEnv('ANTHROPIC_API_KEY')
|
||||||
restoreEnv('ANTHROPIC_AUTH_TOKEN')
|
restoreEnv('ANTHROPIC_AUTH_TOKEN')
|
||||||
@ -85,4 +88,20 @@ describe('managedEnv', () => {
|
|||||||
const health = await fetch(new URL('/health', baseUrl.origin))
|
const health = await fetch(new URL('/health', baseUrl.origin))
|
||||||
expect(health.status).toBe(200)
|
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
|
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,
|
* 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.
|
* these are the keys the desktop host set to orchestrate the subprocess.
|
||||||
@ -94,7 +111,9 @@ function filterSettingsEnv(
|
|||||||
env: Record<string, string> | undefined,
|
env: Record<string, string> | undefined,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
return withoutCcdSpawnEnvKeys(
|
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',
|
'SSH_SIGNING_KEY',
|
||||||
] as const
|
] 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
|
* Returns a copy of process.env with sensitive secrets stripped, for use when
|
||||||
* spawning subprocesses (Bash tool, shell snapshot, MCP stdio servers, LSP
|
* 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
|
// proxy is disabled or not registered (non-CCR), so this is a no-op outside
|
||||||
// CCR containers.
|
// CCR containers.
|
||||||
const proxyEnv = _getUpstreamProxyEnv?.() ?? {}
|
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
|
return Object.keys(proxyEnv).length > 0
|
||||||
? { ...process.env, ...proxyEnv }
|
? { ...process.env, ...proxyEnv }
|
||||||
: process.env
|
: process.env
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = { ...process.env, ...proxyEnv }
|
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) {
|
for (const k of GHA_SUBPROCESS_SCRUB) {
|
||||||
delete env[k]
|
delete env[k]
|
||||||
// GitHub Actions auto-creates INPUT_<NAME> for `with:` inputs, duplicating
|
// GitHub Actions auto-creates INPUT_<NAME> for `with:` inputs, duplicating
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user