cc-haha/src/server/__tests__/cron-scheduler-launcher.test.ts
程序员阿江(Relakkes) 8cce7ac8bb fix: Route Claude attribution by model name
Provider-managed environments should preserve Claude attribution only when the selected model is actually Claude-prefixed. Non-Claude provider models now disable the attribution header so third-party providers do not inherit Claude-specific prompt metadata.

Constraint: Claude model detection is intentionally model-name based to match the requested provider policy.
Rejected: Remove attribution headers globally | official Claude-prefixed models should keep existing behavior.
Confidence: high
Scope-risk: moderate
Directive: Do not broaden this policy without live provider request-shape verification.
Tested: Targeted server, provider, settings, cron, and managed-env tests in prior verification pass.
Not-tested: Full verify gate after this commit.
2026-05-19 18:12:57 +08:00

425 lines
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
buildCronCliArgs,
CronScheduler,
resolveCronProjectRoot,
} from '../services/cronScheduler.js'
import { CronService } from '../services/cronService.js'
import { ProviderService } from '../services/providerService.js'
import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalPath = process.env.PATH
const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH
const originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL
const originalAnthropicModel = process.env.ANTHROPIC_MODEL
const originalClaudeCodeEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
const originalHome = process.env.HOME
const originalShell = process.env.SHELL
const originalZdotdir = process.env.ZDOTDIR
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const isWindows = process.platform === 'win32'
const unixOnly = isWindows ? it.skip : it
async function createTmpDir(): Promise<string> {
const dir = path.join(
os.tmpdir(),
`claude-cron-launcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(dir, { recursive: true })
return dir
}
async function cleanupTmpDir(dir: string): Promise<void> {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
}
async function createSourceRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, 'src', 'entrypoints'), { recursive: true })
await fs.writeFile(path.join(root, 'preload.ts'), '', 'utf-8')
await fs.writeFile(
path.join(root, 'src', 'entrypoints', 'cli.tsx'),
'',
'utf-8',
)
}
function restoreEnv(): void {
if (originalConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalPath) {
process.env.PATH = originalPath
} else {
delete process.env.PATH
}
if (originalClaudeCliPath) {
process.env.CLAUDE_CLI_PATH = originalClaudeCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalClaudeAppRoot) {
process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
} else {
delete process.env.CLAUDE_APP_ROOT
}
if (originalAnthropicBaseUrl) {
process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl
} else {
delete process.env.ANTHROPIC_BASE_URL
}
if (originalAnthropicModel) {
process.env.ANTHROPIC_MODEL = originalAnthropicModel
} else {
delete process.env.ANTHROPIC_MODEL
}
if (originalClaudeCodeEntrypoint) {
process.env.CLAUDE_CODE_ENTRYPOINT = originalClaudeCodeEntrypoint
} else {
delete process.env.CLAUDE_CODE_ENTRYPOINT
}
if (originalHome) {
process.env.HOME = originalHome
} else {
delete process.env.HOME
}
if (originalShell) {
process.env.SHELL = originalShell
} else {
delete process.env.SHELL
}
if (originalZdotdir) {
process.env.ZDOTDIR = originalZdotdir
} else {
delete process.env.ZDOTDIR
}
if (originalDisableTerminalShellEnv) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
resetTerminalShellEnvironmentCacheForTests()
}
describe('cron scheduler launcher resolution', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config')
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
resetTerminalShellEnvironmentCacheForTests()
})
afterEach(async () => {
restoreEnv()
await cleanupTmpDir(tmpDir)
})
it('uses the bundled sidecar launcher when one is configured', () => {
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const appRoot = path.join(tmpDir, 'app-root')
const args = buildCronCliArgs(['--print'], {
cliPath: sidecarPath,
appRoot,
execPath: path.join(tmpDir, 'bun'),
cwd: path.join(tmpDir, 'missing-cwd'),
moduleDir: path.join(tmpDir, 'missing-module'),
env: {},
})
expect(args).toEqual([
sidecarPath,
'cli',
'--app-root',
appRoot,
'--print',
])
})
it('prefers an explicit CC_HAHA_ROOT when it points at a source checkout', async () => {
const sourceRoot = path.join(tmpDir, 'source')
await createSourceRoot(sourceRoot)
expect(
resolveCronProjectRoot({
cwd: path.join(tmpDir, 'other'),
moduleDir: path.join(tmpDir, 'broken', 'src', 'server', 'services'),
env: { CC_HAHA_ROOT: sourceRoot },
}),
).toBe(sourceRoot)
})
it('falls back to the nearest source checkout from cwd before module dir', async () => {
const sourceRoot = path.join(tmpDir, 'source')
const nestedCwd = path.join(sourceRoot, 'nested', 'workdir')
await createSourceRoot(sourceRoot)
await fs.mkdir(nestedCwd, { recursive: true })
expect(
resolveCronProjectRoot({
cwd: nestedCwd,
moduleDir: path.join(tmpDir, 'wrong', 'src', 'server', 'services'),
env: {},
}),
).toBe(sourceRoot)
})
unixOnly('executeTask launches the configured desktop sidecar instead of source bun', async () => {
const binDir = path.join(tmpDir, 'bin')
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const sidecarArgsPath = path.join(tmpDir, 'sidecar.args')
const bunArgsPath = path.join(tmpDir, 'bun.args')
await fs.mkdir(binDir, { recursive: true })
await fs.mkdir(appRoot, { recursive: true })
await fs.writeFile(
path.join(binDir, 'bun'),
[
'#!/bin/sh',
`printf '%s\\n' "$@" > "${bunArgsPath}"`,
'echo "error: Module not found \\"B:\\\\src\\\\entrypoints\\\\cli.tsx\\"" >&2',
'exit 1',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(path.join(binDir, 'bun'), 0o755)
await fs.writeFile(
sidecarPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" > "${sidecarArgsPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"sidecar ok"}\'',
'exit 0',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(sidecarPath, 0o755)
process.env.PATH = binDir
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron sidecar test',
name: 'Sidecar Task',
recurring: true,
folderPath: tmpDir,
})
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('sidecar ok')
const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8'))
.trim()
.split('\n')
expect(sidecarArgs.slice(0, 4)).toEqual([
'cli',
'--app-root',
appRoot,
'--print',
])
expect(sidecarArgs).not.toContain(path.join('src', 'entrypoints', 'cli.tsx'))
const bunWasCalled = await fs
.stat(bunArgsPath)
.then(() => true)
.catch(() => false)
expect(bunWasCalled).toBe(false)
})
unixOnly('executeTask passes provider-scoped model runtime to the sidecar', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const sidecarArgsPath = path.join(tmpDir, 'sidecar.args')
const sidecarEnvPath = path.join(tmpDir, 'sidecar.env')
await fs.mkdir(appRoot, { recursive: true })
await fs.writeFile(
sidecarPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" > "${sidecarArgsPath}"`,
`env | sort > "${sidecarEnvPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"provider ok"}\'',
'exit 0',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(sidecarPath, 0o755)
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
process.env.ANTHROPIC_BASE_URL = 'https://stale-parent.example'
process.env.ANTHROPIC_MODEL = 'stale-parent-model'
process.env.CLAUDE_CODE_ENTRYPOINT = 'stale-parent-entrypoint'
const provider = await new ProviderService().addProvider({
presetId: 'custom',
name: 'Provider A',
apiKey: 'provider-key',
baseUrl: 'https://api.provider.example',
apiFormat: 'openai_chat',
models: {
main: 'provider-main',
haiku: 'provider-fast',
sonnet: 'provider-main',
opus: '',
},
})
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron provider test',
name: 'Provider Task',
recurring: true,
folderPath: tmpDir,
model: 'provider-fast',
providerId: provider.id,
})
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('provider ok')
const sidecarArgs = (await fs.readFile(sidecarArgsPath, 'utf-8'))
.trim()
.split('\n')
expect(sidecarArgs).toContain('--model')
expect(sidecarArgs[sidecarArgs.indexOf('--model') + 1]).toBe('provider-fast')
const env = Object.fromEntries(
(await fs.readFile(sidecarEnvPath, 'utf-8'))
.trim()
.split('\n')
.map((line) => {
const index = line.indexOf('=')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
expect(env.ANTHROPIC_BASE_URL).toBe(
`http://127.0.0.1:3456/proxy/providers/${provider.id}`,
)
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('provider-fast')
expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
})
unixOnly('executeTask inherits exported terminal shell variables', async () => {
const appRoot = path.join(tmpDir, 'app-root')
const sidecarPath = path.join(tmpDir, 'claude-sidecar')
const sidecarEnvPath = path.join(tmpDir, 'sidecar.env')
const shellPath = path.join(tmpDir, 'zsh')
const nodeBin = path.join(tmpDir, 'node-bin')
const nvmDir = path.join(tmpDir, '.nvm')
await fs.mkdir(appRoot, { recursive: true })
await fs.mkdir(nodeBin, { recursive: true })
await fs.mkdir(nvmDir, { recursive: true })
await fs.writeFile(
shellPath,
[
'#!/bin/sh',
'command=',
'while [ "$#" -gt 0 ]; do',
' if [ "$1" = "-c" ]; then',
' shift',
' command="$1"',
' break',
' fi',
' shift',
'done',
'if [ -f "$HOME/.zshrc" ]; then',
' . "$HOME/.zshrc" </dev/null >/dev/null 2>/dev/null || true',
'fi',
'exec /bin/sh -c "$command"',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(shellPath, 0o755)
await fs.writeFile(
path.join(tmpDir, '.zshrc'),
[
`export NVM_DIR="${nvmDir}"`,
`export PATH="${nodeBin}:$PATH"`,
'',
].join('\n'),
)
await fs.writeFile(
sidecarPath,
[
'#!/bin/sh',
`env | sort > "${sidecarEnvPath}"`,
'/bin/cat >/dev/null',
'printf \'%s\\n\' \'{"type":"result","result":"shell env ok"}\'',
'exit 0',
'',
].join('\n'),
'utf-8',
)
await fs.chmod(sidecarPath, 0o755)
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
process.env.HOME = tmpDir
process.env.SHELL = shellPath
process.env.PATH = '/usr/bin:/bin'
delete process.env.ZDOTDIR
process.env.CLAUDE_CLI_PATH = sidecarPath
process.env.CLAUDE_APP_ROOT = appRoot
resetTerminalShellEnvironmentCacheForTests()
const cronService = new CronService()
const scheduler = new CronScheduler(cronService)
const task = await cronService.createTask({
cron: '* * * * *',
prompt: 'cron shell env test',
name: 'Shell Env Task',
recurring: true,
folderPath: tmpDir,
})
const run = await scheduler.executeTask(task)
expect(run.status).toBe('completed')
expect(run.output).toBe('shell env ok')
const env = Object.fromEntries(
(await fs.readFile(sidecarEnvPath, 'utf-8'))
.trim()
.split('\n')
.map((line) => {
const index = line.indexOf('=')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
expect(env.NVM_DIR).toBe(nvmDir)
expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin)
})
})