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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-22 23:45:15 +08:00
parent cd081116cf
commit a090070352
6 changed files with 156 additions and 5 deletions

View File

@ -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 () => {

View File

@ -0,0 +1,31 @@
import { handleProxyRequest } from './handler.js'
let standaloneProviderProxy: ReturnType<typeof Bun.serve> | 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
}

View File

@ -690,6 +690,8 @@ export class CronScheduler {
return [
...(model ? ['--model', model] : []),
'--dangerously-skip-permissions',
'--permission-mode',
'bypassPermissions',
]
}

View File

@ -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<string, string>,
configDir: string,

View File

@ -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<void> {
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)
})
})

View File

@ -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<string, string> {
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<string, string> }
const settingsEnv = normalizeLegacyDeepSeekManagedEnv(parsed.env ?? {}).env
return mergeActiveProviderManagedEnv(settingsEnv, getClaudeConfigHomeDir())
return mergeActiveProviderManagedEnv(settingsEnv, configDir, { serverPort })
} catch {
return mergeActiveProviderManagedEnv({}, getClaudeConfigHomeDir())
return mergeActiveProviderManagedEnv({}, configDir, { serverPort })
}
}