mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
23b31b397a
commit
b002447436
@ -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 "$@"
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -117,11 +117,34 @@ export class ConversationService {
|
||||
// STATE.cwd / "Primary working directory" 打回根目录,IM 会话里 AI 感知的
|
||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.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>
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user