fix: provider 隔离与 Claude 官方认证修复

- provider 配置写入 ~/.claude/cc-haha/settings.json,不再污染原版 settings.json
- 子进程 env 剥离 PROVIDER_ENV_KEYS,防止残留 key 传递
- bin/claude-haha 在 CC_HAHA_SKIP_DOTENV=1 时用 --env-file=/dev/null 阻止 Bun 自动加载 .env
- managedEnv.ts 增加 getCcHahaSettingsEnv() 读取隔离配置
- 新增 /api/providers/auth-status 端点,启动时检测认证状态
- AppShell 启动时无认证则跳转 provider 设置页
This commit is contained in:
程序员阿江(Relakkes) 2026-04-15 14:18:25 +08:00
parent 23b31b397a
commit b002447436
9 changed files with 181 additions and 7 deletions

View File

@ -3,14 +3,28 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
#Get your current working directory and export it as an environment variable.
export CALLER_DIR="$(pwd -W 2>/dev/null || pwd)"
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
cd "$ROOT_DIR"
# When spawned by the desktop/web server as a child CLI process,
# skip .env loading — the server has already set the correct env
# via cc-haha/settings.json. Loading .env would re-inject stale
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
# override the active provider config.
if [[ "${CC_HAHA_SKIP_DOTENV:-0}" == "1" ]]; then
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
ENV_FILE_FLAG="--env-file=/dev/null"
elif [[ -f .env ]]; then
ENV_FILE_FLAG="--env-file=.env"
else
ENV_FILE_FLAG=""
fi
# Force recovery CLI (simple readline REPL, no Ink TUI)
if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then
exec bun --env-file=.env ./src/localRecoveryCli.ts "$@"
exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
fi
# Default: full CLI with Ink TUI
exec bun --env-file=.env ./src/entrypoints/cli.tsx "$@"
exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"

View File

@ -14,6 +14,11 @@ type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null }
type ProviderResponse = { provider: SavedProvider }
type PresetsResponse = { presets: ProviderPreset[] }
type TestResultResponse = { result: ProviderTestResult }
type AuthStatusResponse = {
hasAuth: boolean
source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none'
activeProvider?: string
}
export const providersApi = {
list() {
@ -24,6 +29,10 @@ export const providersApi = {
return api.get<PresetsResponse>('/api/providers/presets')
},
authStatus() {
return api.get<AuthStatusResponse>('/api/providers/auth-status')
},
create(input: CreateProviderInput) {
return api.post<ProviderResponse>('/api/providers', input)
},

View File

@ -11,6 +11,7 @@ import { TabBar } from './TabBar'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { providersApi } from '../../api/providers'
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
@ -25,6 +26,19 @@ export function AppShell() {
try {
await initializeDesktopServerUrl()
await fetchSettings()
// Check auth status: if no auth at all, redirect to provider setup
try {
const authStatus = await providersApi.authStatus()
if (!authStatus.hasAuth) {
// No auth — open Settings on the providers tab
useUIStore.getState().setPendingSettingsTab('providers')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
} catch {
// auth-status check failed — continue normally
}
// Restore tabs from localStorage
await useTabStore.getState().restoreTabs()
const activeId = useTabStore.getState().activeTabId

View File

@ -3,6 +3,7 @@
*
* GET /api/providers list all saved providers + activeId
* GET /api/providers/presets list available presets
* GET /api/providers/auth-status check whether any usable auth exists
* POST /api/providers add a provider
* PUT /api/providers/:id update a provider
* DELETE /api/providers/:id delete a provider
@ -55,6 +56,12 @@ export async function handleProvidersApi(
return Response.json({ presets: PROVIDER_PRESETS })
}
// GET /api/providers/auth-status
if (id === 'auth-status' && req.method === 'GET') {
const status = await providerService.checkAuthStatus()
return Response.json(status)
}
// POST /api/providers/official
if (id === 'official' && req.method === 'POST') {
await providerService.activateOfficial()

View File

@ -217,6 +217,35 @@ export function startServer(port = PORT, host = HOST) {
return server
}
// ─── Graceful shutdown: kill all CLI subprocesses on exit ────────────────────
import { conversationService } from './services/conversationService.js'
function cleanupAllSessions() {
const active = conversationService.getActiveSessions()
if (active.length > 0) {
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
for (const sessionId of active) {
conversationService.stopSession(sessionId)
}
}
}
process.on('SIGTERM', () => {
console.log('[Server] Received SIGTERM')
cleanupAllSessions()
process.exit(0)
})
process.on('SIGINT', () => {
console.log('[Server] Received SIGINT')
cleanupAllSessions()
process.exit(0)
})
process.on('exit', () => {
cleanupAllSessions()
})
// Direct execution
if (import.meta.main) {
startServer()

View File

@ -15,6 +15,7 @@ import { handleFilesystemRoute } from './api/filesystem.js'
import { handleProvidersApi } from './api/providers.js'
import { handleAdaptersApi } from './api/adapters.js'
import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -71,6 +72,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'skills':
return handleSkillsApi(req, url, segments)
case 'computer-use':
return handleComputerUseApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -117,11 +117,34 @@ export class ConversationService {
// STATE.cwd / "Primary working directory" 打回根目录IM 会话里 AI 感知的
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
// Provider isolation: strip provider-managed env vars from the inherited
// process.env so the CLI subprocess reads them fresh from config files
// (cc-haha/settings.json or ~/.claude/settings.json). Without this,
// switching to "Claude 官方" in the desktop UI would still send requests
// to the previous provider because stale env vars linger in the sidecar's
// process.env after activateOfficial() only clears the config file.
const PROVIDER_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
]
const cleanEnv = { ...process.env }
for (const key of PROVIDER_ENV_KEYS) {
delete cleanEnv[key]
}
const childEnv = {
...process.env,
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
CALLER_DIR: workDir,
PWD: workDir,
// Tell bin/claude-haha to skip .env loading — provider env is managed
// by cc-haha/settings.json, not the project's .env file.
CC_HAHA_SKIP_DOTENV: '1',
}
let proc: ReturnType<typeof Bun.spawn>

View File

@ -2,7 +2,8 @@
* Provider Service preset-based provider configuration
*
* Storage: ~/.claude/cc-haha/providers.json (lightweight index)
* Active provider env vars written to ~/.claude/settings.json
* Active provider env vars written to ~/.claude/cc-haha/settings.json
* (isolated from the original Claude Code's ~/.claude/settings.json)
*/
import * as fs from 'fs/promises'
@ -59,7 +60,7 @@ export class ProviderService {
}
private getSettingsPath(): string {
return path.join(this.getConfigDir(), 'settings.json')
return path.join(this.getCcHahaDir(), 'settings.json')
}
private async readIndex(): Promise<ProvidersIndex> {
@ -250,6 +251,50 @@ export class ProviderService {
await this.writeSettings(settings)
}
// --- Auth status ---
/**
* Check whether any usable auth exists:
* 1. A cc-haha provider is active has auth
* 2. Original ~/.claude/settings.json has ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY has auth
* 3. process.env already has ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN has auth
* 4. None of the above needs setup
*/
async checkAuthStatus(): Promise<{
hasAuth: boolean
source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none'
activeProvider?: string
}> {
// 1. Check cc-haha active provider
const index = await this.readIndex()
if (index.activeId) {
const provider = index.providers.find(p => p.id === index.activeId)
if (provider?.apiKey) {
return { hasAuth: true, source: 'cc-haha-provider', activeProvider: provider.name }
}
}
// 2. Check process.env (covers .env file + inherited env)
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) {
return { hasAuth: true, source: 'env' }
}
// 3. Check original ~/.claude/settings.json
try {
const originalPath = path.join(this.getConfigDir(), 'settings.json')
const raw = await fs.readFile(originalPath, 'utf-8')
const settings = JSON.parse(raw) as { env?: Record<string, string> }
const env = settings.env ?? {}
if (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY) {
return { hasAuth: true, source: 'original-settings' }
}
} catch {
// File doesn't exist or invalid
}
return { hasAuth: false, source: 'none' }
}
// --- Proxy support ---
async getActiveProviderForProxy(): Promise<{

View File

@ -1,7 +1,9 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { isRemoteManagedSettingsEligible } from '../services/remoteManagedSettings/syncCache.js'
import { clearCACertsCache } from './caCerts.js'
import { getGlobalConfig } from './config.js'
import { isEnvTruthy } from './envUtils.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
import {
isProviderManagedEnvVar,
SAFE_ENV_VARS,
@ -90,6 +92,23 @@ function filterSettingsEnv(
)
}
/**
* Read env vars from ~/.claude/cc-haha/settings.json (Haha-specific provider
* config). This file is written by ProviderService.syncToSettings() and
* contains ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, model defaults, etc.
* Returns an empty object if the file doesn't exist or is invalid.
*/
function getCcHahaSettingsEnv(): Record<string, string> {
try {
const ccHahaSettings = join(getClaudeConfigHomeDir(), 'cc-haha', 'settings.json')
const raw = readFileSync(ccHahaSettings, 'utf-8')
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
return parsed.env ?? {}
} catch {
return {}
}
}
/**
* Trusted setting sources whose env vars can be applied before the trust dialog.
*
@ -148,6 +167,12 @@ export function applySafeConfigEnvironmentVariables(): void {
)
}
// cc-haha provider isolation: apply env from ~/.claude/cc-haha/settings.json
// AFTER userSettings so Haha-specific provider config takes priority over
// the original Claude Code's settings. This prevents Haha from polluting
// ~/.claude/settings.json while still allowing it to override provider vars.
Object.assign(process.env, filterSettingsEnv(getCcHahaSettingsEnv()))
// Compute remote-managed-settings eligibility now, with userSettings and
// flagSettings env applied. Eligibility reads CLAUDE_CODE_USE_BEDROCK,
// ANTHROPIC_BASE_URL — both settable via settings.env.
@ -189,6 +214,10 @@ export function applyConfigEnvironmentVariables(): void {
Object.assign(process.env, filterSettingsEnv(getSettings_DEPRECATED()?.env))
// cc-haha provider isolation: same as in applySafeConfigEnvironmentVariables,
// apply Haha-specific env last so it overrides the original settings.
Object.assign(process.env, filterSettingsEnv(getCcHahaSettingsEnv()))
// Clear caches so agents are rebuilt with the new env vars
clearCACertsCache()
clearMTLSCache()