From a0900703526730f1187b8428c8893a63e7948c7d 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: Mon, 22 Jun 2026 23:45:15 +0800 Subject: [PATCH] fix(server): repair provider proxy and cron permissions Fix #845 by starting a lightweight standalone provider proxy for CLI-only OpenAI-compatible desktop providers, avoiding a fixed localhost:3456 proxy when the desktop server is not running. Fix #847 by passing explicit --permission-mode bypassPermissions for scheduled task subprocesses alongside the dangerous-skip flag. Tested: bun test src/utils/managedEnv.test.ts src/server/__tests__/provider-runtime-env.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts Tested: bun run check:server Confidence: high Scope-risk: moderate --- .../__tests__/cron-scheduler-launcher.test.ts | 5 +- src/server/proxy/standaloneProviderProxy.ts | 31 +++++++ src/server/services/cronScheduler.ts | 2 + src/server/services/providerRuntimeEnv.ts | 17 ++++ src/utils/managedEnv.test.ts | 88 +++++++++++++++++++ src/utils/managedEnv.ts | 18 +++- 6 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/server/proxy/standaloneProviderProxy.ts create mode 100644 src/utils/managedEnv.test.ts diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 59789479..1a1c8a4c 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -474,7 +474,10 @@ describe('cron scheduler launcher resolution', () => { .trim() .split('\n') expect(sidecarArgs).toContain('--dangerously-skip-permissions') - expect(sidecarArgs).not.toContain('--permission-mode') + expect(sidecarArgs).toContain('--permission-mode') + expect(sidecarArgs[sidecarArgs.indexOf('--permission-mode') + 1]).toBe( + 'bypassPermissions', + ) }) unixOnly('executeTask inherits exported terminal shell variables', async () => { diff --git a/src/server/proxy/standaloneProviderProxy.ts b/src/server/proxy/standaloneProviderProxy.ts new file mode 100644 index 00000000..51acb4e1 --- /dev/null +++ b/src/server/proxy/standaloneProviderProxy.ts @@ -0,0 +1,31 @@ +import { handleProxyRequest } from './handler.js' + +let standaloneProviderProxy: ReturnType | null = null + +export function ensureStandaloneProviderProxy(): number { + if (standaloneProviderProxy) { + return standaloneProviderProxy.port + } + + standaloneProviderProxy = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url) + if (url.pathname === '/health') { + return Response.json({ status: 'ok' }) + } + if (url.pathname.startsWith('/proxy/')) { + return handleProxyRequest(req, url) + } + return Response.json({ error: 'Not Found' }, { status: 404 }) + }, + }) + + return standaloneProviderProxy.port +} + +export function stopStandaloneProviderProxyForTests(): void { + standaloneProviderProxy?.stop(true) + standaloneProviderProxy = null +} diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 2caf3951..376e029d 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -690,6 +690,8 @@ export class CronScheduler { return [ ...(model ? ['--model', model] : []), '--dangerously-skip-permissions', + '--permission-mode', + 'bypassPermissions', ] } diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts index e42facb7..0314a82f 100644 --- a/src/server/services/providerRuntimeEnv.ts +++ b/src/server/services/providerRuntimeEnv.ts @@ -379,6 +379,23 @@ export function readActiveProviderManagedEnv( } } +export function activeProviderNeedsProxy(configDir: string): boolean { + try { + const raw = fs.readFileSync(path.join(configDir, 'cc-haha', 'providers.json'), 'utf-8') + const index = normalizeProvidersIndex(JSON.parse(raw)) + if (!index?.activeId || isOpenAIOfficialProviderId(index.activeId)) { + return false + } + + const provider = index.providers.find((entry) => entry.id === index.activeId) + if (!provider) return false + + return (provider.apiFormat ?? 'anthropic') !== 'anthropic' + } catch { + return false + } +} + export function mergeActiveProviderManagedEnv( settingsEnv: Record, configDir: string, diff --git a/src/utils/managedEnv.test.ts b/src/utils/managedEnv.test.ts new file mode 100644 index 00000000..2cf5bfbf --- /dev/null +++ b/src/utils/managedEnv.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' + +import { applySafeConfigEnvironmentVariables } from './managedEnv.js' + +let tmpDir: string +const originalEnv = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, +} + +function restoreEnv(key: keyof typeof originalEnv): void { + const value = originalEnv[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8') +} + +describe('managedEnv', () => { + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'managed-env-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST + delete process.env.ANTHROPIC_BASE_URL + delete process.env.ANTHROPIC_API_KEY + delete process.env.ANTHROPIC_AUTH_TOKEN + delete process.env.ANTHROPIC_MODEL + }) + + afterEach(async () => { + await import('../server/proxy/standaloneProviderProxy.js') + .then((mod) => mod.stopStandaloneProviderProxyForTests?.()) + .catch(() => {}) + await fs.rm(tmpDir, { recursive: true, force: true }) + restoreEnv('CLAUDE_CONFIG_DIR') + restoreEnv('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST') + restoreEnv('ANTHROPIC_BASE_URL') + restoreEnv('ANTHROPIC_API_KEY') + restoreEnv('ANTHROPIC_AUTH_TOKEN') + restoreEnv('ANTHROPIC_MODEL') + }) + + test('starts a standalone provider proxy for CLI-only OpenAI-compatible providers', async () => { + await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), { + activeId: 'agnes-provider', + providers: [ + { + id: 'agnes-provider', + presetId: 'custom', + name: 'Agnes', + apiKey: 'sk-agnes', + authStrategy: 'api_key', + baseUrl: 'https://apihub.agnes-ai.com', + apiFormat: 'openai_chat', + models: { + main: 'agnes-2.0-flash', + haiku: 'agnes-2.0-flash', + sonnet: 'agnes-2.0-flash', + opus: 'agnes-2.0-flash', + }, + }, + ], + }) + + applySafeConfigEnvironmentVariables() + + const baseUrl = new URL(process.env.ANTHROPIC_BASE_URL!) + expect(baseUrl.hostname).toBe('127.0.0.1') + expect(baseUrl.port).not.toBe('3456') + expect(baseUrl.pathname).toBe('/proxy') + + const health = await fetch(new URL('/health', baseUrl.origin)) + expect(health.status).toBe(200) + }) +}) diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index db39f798..1e610cfc 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -1,7 +1,11 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { isRemoteManagedSettingsEligible } from '../services/remoteManagedSettings/syncCache.js' -import { mergeActiveProviderManagedEnv } from '../server/services/providerRuntimeEnv.js' +import { + activeProviderNeedsProxy, + mergeActiveProviderManagedEnv, +} from '../server/services/providerRuntimeEnv.js' +import { ensureStandaloneProviderProxy } from '../server/proxy/standaloneProviderProxy.js' import { clearCACertsCache } from './caCerts.js' import { getGlobalConfig } from './config.js' import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' @@ -101,14 +105,20 @@ function filterSettingsEnv( * Returns an empty object if the file doesn't exist or is invalid. */ function getCcHahaSettingsEnv(): Record { + const configDir = getClaudeConfigHomeDir() + const serverPort = + !isEnvTruthy(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST) && + activeProviderNeedsProxy(configDir) + ? ensureStandaloneProviderProxy() + : undefined try { - const ccHahaSettings = join(getClaudeConfigHomeDir(), 'cc-haha', 'settings.json') + const ccHahaSettings = join(configDir, 'cc-haha', 'settings.json') const raw = readFileSync(ccHahaSettings, 'utf-8') const parsed = JSON.parse(raw) as { env?: Record } const settingsEnv = normalizeLegacyDeepSeekManagedEnv(parsed.env ?? {}).env - return mergeActiveProviderManagedEnv(settingsEnv, getClaudeConfigHomeDir()) + return mergeActiveProviderManagedEnv(settingsEnv, configDir, { serverPort }) } catch { - return mergeActiveProviderManagedEnv({}, getClaudeConfigHomeDir()) + return mergeActiveProviderManagedEnv({}, configDir, { serverPort }) } }