mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
feat(desktop): inject CLAUDE_CODE_OAUTH_TOKEN from haha oauth.json
在 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) <noreply@anthropic.com>
This commit is contained in:
parent
7421db2857
commit
94b445cd06
@ -10,6 +10,7 @@ describe('ConversationService', () => {
|
|||||||
let originalAuthToken: string | undefined
|
let originalAuthToken: string | undefined
|
||||||
let originalBaseUrl: string | undefined
|
let originalBaseUrl: string | undefined
|
||||||
let originalModel: string | undefined
|
let originalModel: string | undefined
|
||||||
|
let originalEntrypoint: string | undefined
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
|
||||||
@ -17,11 +18,15 @@ describe('ConversationService', () => {
|
|||||||
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
|
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
|
||||||
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
|
||||||
originalModel = process.env.ANTHROPIC_MODEL
|
originalModel = process.env.ANTHROPIC_MODEL
|
||||||
|
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
|
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
|
||||||
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
|
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
|
||||||
process.env.ANTHROPIC_MODEL = 'test-model'
|
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 () => {
|
afterEach(async () => {
|
||||||
@ -37,12 +42,15 @@ describe('ConversationService', () => {
|
|||||||
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
|
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
|
||||||
else process.env.ANTHROPIC_MODEL = originalModel
|
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 })
|
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 service = new ConversationService() as any
|
||||||
const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record<string, string>
|
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
|
||||||
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
|
||||||
@ -59,13 +67,63 @@ describe('ConversationService', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const service = new ConversationService() as any
|
const service = new ConversationService() as any
|
||||||
const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record<string, string>
|
const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record<string, string>
|
||||||
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||||
expect(env.ANTHROPIC_MODEL).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<string, string>
|
||||||
|
|
||||||
|
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<string, string>
|
||||||
|
|
||||||
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||||
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
||||||
const service = new ConversationService() as any
|
const service = new ConversationService() as any
|
||||||
const args = service.resolveCliArgs(['--print'])
|
const args = service.resolveCliArgs(['--print'])
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export class ConversationService {
|
|||||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
||||||
// chdir 后落到正确目录。
|
// chdir 后落到正确目录。
|
||||||
//
|
//
|
||||||
const childEnv = this.buildChildEnv(workDir)
|
const childEnv = await this.buildChildEnv(workDir)
|
||||||
|
|
||||||
let proc: ReturnType<typeof Bun.spawn>
|
let proc: ReturnType<typeof Bun.spawn>
|
||||||
try {
|
try {
|
||||||
@ -471,7 +471,7 @@ export class ConversationService {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildChildEnv(workDir: string): Record<string, string> {
|
private async buildChildEnv(workDir: string): Promise<Record<string, string>> {
|
||||||
// Provider isolation: when Desktop has its own provider config/index,
|
// Provider isolation: when Desktop has its own provider config/index,
|
||||||
// strip inherited provider env vars so the child CLI reads fresh values
|
// strip inherited provider env vars so the child CLI reads fresh values
|
||||||
// from ~/.claude/cc-haha/settings.json instead of stale process.env.
|
// from ~/.claude/cc-haha/settings.json instead of stale process.env.
|
||||||
@ -511,11 +511,34 @@ export class ConversationService {
|
|||||||
// 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方
|
// 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方
|
||||||
// endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。
|
// endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。
|
||||||
...(this.shouldMarkManagedOAuth()
|
...(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<Record<string, string>> {
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
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 {
|
private shouldStripInheritedProviderEnv(): boolean {
|
||||||
const configDir =
|
const configDir =
|
||||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user