From 94b445cd061abe7e4f39b381626aeab201cb80b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 17 Apr 2026 20:12:43 +0800 Subject: [PATCH] feat(desktop): inject CLAUDE_CODE_OAUTH_TOKEN from haha oauth.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 buildChildEnv 官方模式分支下,调 hahaOAuthService.ensureFreshAccessToken() (自动 refresh),成功则通过 env 注入给 CLI 子进程。 CLI 的 src/utils/auth.ts:1260 优先读 CLAUDE_CODE_OAUTH_TOKEN env,完全 不碰 Keychain —— DMG 装的 .app 在 macOS 下也能正常用官方 Claude,不再 403。 buildChildEnv 签名改为 async, 所有 caller 加 await。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/conversation-service.test.ts | 64 ++++++++++++++++++- src/server/services/conversationService.ts | 29 ++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 597f00cf..63d5c7aa 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -10,6 +10,7 @@ describe('ConversationService', () => { let originalAuthToken: string | undefined let originalBaseUrl: string | undefined let originalModel: string | undefined + let originalEntrypoint: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-')) @@ -17,11 +18,15 @@ describe('ConversationService', () => { originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN originalBaseUrl = process.env.ANTHROPIC_BASE_URL originalModel = process.env.ANTHROPIC_MODEL + originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.ANTHROPIC_AUTH_TOKEN = 'test-token' process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic' process.env.ANTHROPIC_MODEL = 'test-model' + // Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether + // buildChildEnv injects it or not without interference from the shell env. + delete process.env.CLAUDE_CODE_ENTRYPOINT }) afterEach(async () => { @@ -37,12 +42,15 @@ describe('ConversationService', () => { if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL else process.env.ANTHROPIC_MODEL = originalModel + if (originalEntrypoint === undefined) delete process.env.CLAUDE_CODE_ENTRYPOINT + else process.env.CLAUDE_CODE_ENTRYPOINT = originalEntrypoint + await fs.rm(tmpDir, { recursive: true, force: true }) }) - test('keeps inherited provider env when no desktop provider config exists', () => { + test('keeps inherited provider env when no desktop provider config exists', async () => { const service = new ConversationService() as any - const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record + const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token') expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic') @@ -59,13 +67,63 @@ describe('ConversationService', () => { ) const service = new ConversationService() as any - const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record + const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() expect(env.ANTHROPIC_BASE_URL).toBeUndefined() expect(env.ANTHROPIC_MODEL).toBeUndefined() }) + test('buildChildEnv injects CLAUDE_CODE_OAUTH_TOKEN when official mode + haha oauth token exists', async () => { + const ccHahaDir = path.join(tmpDir, 'cc-haha') + await fs.mkdir(ccHahaDir, { recursive: true }) + await fs.writeFile( + path.join(ccHahaDir, 'settings.json'), + JSON.stringify({ env: {} }), + 'utf-8', + ) + + const { hahaOAuthService } = await import('../services/hahaOAuthService.js') + await hahaOAuthService.saveTokens({ + accessToken: 'haha-fresh-token', + refreshToken: 'haha-refresh-xxx', + expiresAt: Date.now() + 30 * 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop') + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('haha-fresh-token') + }) + + test('buildChildEnv does NOT inject CLAUDE_CODE_OAUTH_TOKEN when not official mode', async () => { + const ccHahaDir = path.join(tmpDir, 'cc-haha') + await fs.mkdir(ccHahaDir, { recursive: true }) + await fs.writeFile( + path.join(ccHahaDir, 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }), + 'utf-8', + ) + + const { hahaOAuthService } = await import('../services/hahaOAuthService.js') + await hahaOAuthService.saveTokens({ + accessToken: 'haha-token-should-not-be-used', + refreshToken: null, + expiresAt: null, + scopes: [], + subscriptionType: null, + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() + }) + test('uses bun entrypoint fallback on Windows dev mode', () => { const service = new ConversationService() as any const args = service.resolveCliArgs(['--print']) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 8d431077..46dfae77 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -119,7 +119,7 @@ export class ConversationService { // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts // chdir 后落到正确目录。 // - const childEnv = this.buildChildEnv(workDir) + const childEnv = await this.buildChildEnv(workDir) let proc: ReturnType try { @@ -471,7 +471,7 @@ export class ConversationService { return args } - private buildChildEnv(workDir: string): Record { + private async buildChildEnv(workDir: string): Promise> { // Provider isolation: when Desktop has its own provider config/index, // strip inherited provider env vars so the child CLI reads fresh values // from ~/.claude/cc-haha/settings.json instead of stale process.env. @@ -511,11 +511,34 @@ export class ConversationService { // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方 // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。 ...(this.shouldMarkManagedOAuth() - ? { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } + ? await this.buildOfficialOAuthEnv() : {}), } } + /** + * 官方模式下构造 CLI 子进程的 auth env: + * - CLAUDE_CODE_ENTRYPOINT=claude-desktop 让 CLI 忽略外部残留 ANTHROPIC_* env + * - 如果 haha 自管的 oauth.json 里有可用 token,注入 CLAUDE_CODE_OAUTH_TOKEN + * 让 CLI 直接拿 env 里的 token,不碰 Keychain,绕开 macOS ACL 静默拒绝 + * (这是 DMG 安装 .app 后 403 "Request not allowed" 的唯一根治方案) + */ + private async buildOfficialOAuthEnv(): Promise> { + const env: Record = { + CLAUDE_CODE_ENTRYPOINT: 'claude-desktop', + } + try { + const { hahaOAuthService } = await import('./hahaOAuthService.js') + const token = await hahaOAuthService.ensureFreshAccessToken() + if (token) { + env.CLAUDE_CODE_OAUTH_TOKEN = token + } + } catch (err) { + console.error('[conversationService] ensureFreshAccessToken failed:', err) + } + return env + } + private shouldStripInheritedProviderEnv(): boolean { const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')