mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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.
425 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|