mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Support provider runtimes for scheduled tasks
Scheduled tasks used the model selector in model-only mode, so a task could store a third-party model id without the provider runtime needed to execute it. The desktop task editor now stores the selected provider with the model, and the scheduler injects the same provider-scoped runtime env that desktop sessions use before launching the sidecar. Constraint: Scheduled tasks run outside an active desktop websocket session, so provider runtime must be serialized on the task itself. Rejected: Reuse session runtime localStorage for tasks | scheduled executions can run long after the UI session is gone. Confidence: high Scope-risk: moderate Directive: Keep task execution provider env isolated from inherited process env; stale parent ANTHROPIC_* values must not override task provider choices. Tested: cd desktop && bun run test -- src/components/tasks/NewTaskModal.test.tsx src/components/controls/ModelSelector.test.tsx Tested: bun test src/server/__tests__/scheduled-tasks.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts Tested: bun run check:desktop Tested: bun run check:server Tested: git diff --check Not-tested: Live third-party provider API call; covered by sidecar launch integration with provider proxy env.
This commit is contained in:
parent
de9d044bdb
commit
7696e21dc2
@ -19,6 +19,8 @@ type ProviderChoice = {
|
||||
type Props = {
|
||||
value?: string
|
||||
onChange?: (modelId: string) => void
|
||||
runtimeSelection?: RuntimeSelection
|
||||
onRuntimeSelectionChange?: (selection: RuntimeSelection) => void
|
||||
runtimeKey?: string
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
@ -104,6 +106,8 @@ function resolveDefaultRuntimeSelection(
|
||||
export function ModelSelector({
|
||||
value,
|
||||
onChange,
|
||||
runtimeSelection: controlledRuntimeSelection,
|
||||
onRuntimeSelectionChange,
|
||||
runtimeKey,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
@ -138,7 +142,9 @@ export function ModelSelector({
|
||||
]
|
||||
|
||||
const isControlled = value !== undefined
|
||||
const isRuntimeScoped = !isControlled && runtimeKey !== undefined
|
||||
const isRuntimeScoped =
|
||||
!isControlled &&
|
||||
(runtimeKey !== undefined || onRuntimeSelectionChange !== undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
|
||||
@ -188,7 +194,7 @@ export function ModelSelector({
|
||||
: storeModel
|
||||
|
||||
const activeRuntimeSelection = isRuntimeScoped
|
||||
? runtimeSelection ?? resolveDefaultRuntimeSelection(
|
||||
? controlledRuntimeSelection ?? runtimeSelection ?? resolveDefaultRuntimeSelection(
|
||||
activeId,
|
||||
activeProviderName,
|
||||
providers,
|
||||
@ -218,10 +224,12 @@ export function ModelSelector({
|
||||
: null
|
||||
|
||||
const handleRuntimeSelect = (selection: RuntimeSelection) => {
|
||||
if (!runtimeKey) return
|
||||
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
|
||||
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
|
||||
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
|
||||
onRuntimeSelectionChange?.(selection)
|
||||
if (runtimeKey) {
|
||||
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
|
||||
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
|
||||
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
93
desktop/src/components/tasks/NewTaskModal.test.tsx
Normal file
93
desktop/src/components/tasks/NewTaskModal.test.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { NewTaskModal } from './NewTaskModal'
|
||||
import { useAdapterStore } from '../../stores/adapterStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAdapterStore.setState(useAdapterStore.getInitialState(), true)
|
||||
useProviderStore.setState(useProviderStore.getInitialState(), true)
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
|
||||
useTaskStore.setState(useTaskStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('NewTaskModal', () => {
|
||||
it('creates scheduled tasks with a provider-scoped model selection', async () => {
|
||||
const createTask = vi.fn(async () => {})
|
||||
useTaskStore.setState({ createTask } as Partial<ReturnType<typeof useTaskStore.getState>>)
|
||||
useAdapterStore.setState({
|
||||
fetchConfig: vi.fn(async () => {}),
|
||||
config: {},
|
||||
} as Partial<ReturnType<typeof useAdapterStore.getState>>)
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
currentModel: {
|
||||
id: 'provider-main',
|
||||
name: 'provider-main',
|
||||
description: '',
|
||||
context: '',
|
||||
},
|
||||
availableModels: [
|
||||
{ id: 'claude-sonnet-4-6', name: 'Sonnet', description: '', context: '' },
|
||||
],
|
||||
activeProviderName: 'Provider A',
|
||||
})
|
||||
useProviderStore.setState({
|
||||
providers: [{
|
||||
id: 'provider-a',
|
||||
presetId: 'custom',
|
||||
name: 'Provider A',
|
||||
apiKey: '***',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main: 'provider-main',
|
||||
haiku: 'provider-fast',
|
||||
sonnet: 'provider-main',
|
||||
opus: '',
|
||||
},
|
||||
}],
|
||||
activeId: 'provider-a',
|
||||
hasLoadedProviders: true,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<NewTaskModal open onClose={vi.fn()} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^Name/), {
|
||||
target: { value: 'provider cron' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText(/^Description/), {
|
||||
target: { value: 'exercise provider selection' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(/Look at the commits/i), {
|
||||
target: { value: 'Say hello from the scheduled task.' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /provider-main/i }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /provider-fast/i }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(createTask).toHaveBeenCalledTimes(1))
|
||||
expect(createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: 'provider-fast',
|
||||
providerId: 'provider-a',
|
||||
enabled: true,
|
||||
recurring: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -93,6 +93,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
const [frequency, setFrequency] = useState<FrequencyKey>(parsed?.frequency || 'daily')
|
||||
const [time, setTime] = useState(parsed?.time || '09:00')
|
||||
const [model, setModel] = useState(editTask?.model || '')
|
||||
const [providerId, setProviderId] = useState<string | null | undefined>(editTask?.providerId)
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>((editTask?.permissionMode as PermissionMode) || 'default')
|
||||
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
||||
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
||||
@ -132,6 +133,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
cron: cronValue,
|
||||
prompt: prompt.trim(),
|
||||
model: model || undefined,
|
||||
providerId,
|
||||
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
||||
folderPath: folderPath.trim() || undefined,
|
||||
useWorktree: useWorktree || undefined,
|
||||
@ -202,6 +204,8 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
onPermissionModeChange={setPermissionMode}
|
||||
modelId={model}
|
||||
onModelChange={setModel}
|
||||
providerId={providerId}
|
||||
onProviderIdChange={setProviderId}
|
||||
folderPath={folderPath}
|
||||
onFolderPathChange={setFolderPath}
|
||||
useWorktree={useWorktree}
|
||||
|
||||
@ -14,6 +14,8 @@ type Props = {
|
||||
|
||||
modelId: string
|
||||
onModelChange: (modelId: string) => void
|
||||
providerId?: string | null
|
||||
onProviderIdChange: (providerId: string | null) => void
|
||||
|
||||
folderPath: string
|
||||
onFolderPathChange: (path: string) => void
|
||||
@ -30,6 +32,8 @@ export function PromptEditor({
|
||||
onPermissionModeChange,
|
||||
modelId,
|
||||
onModelChange,
|
||||
providerId,
|
||||
onProviderIdChange,
|
||||
folderPath,
|
||||
onFolderPathChange,
|
||||
useWorktree: _useWorktree,
|
||||
@ -53,7 +57,13 @@ export function PromptEditor({
|
||||
{/* Row 1: Permission + Model selectors */}
|
||||
<div className="flex items-center justify-between">
|
||||
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} workDir={folderPath || undefined} />
|
||||
<ModelSelector value={modelId} onChange={onModelChange} />
|
||||
<ModelSelector
|
||||
runtimeSelection={modelId ? { providerId: providerId ?? null, modelId } : undefined}
|
||||
onRuntimeSelectionChange={(selection) => {
|
||||
onProviderIdChange(selection.providerId)
|
||||
onModelChange(selection.modelId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Folder picker */}
|
||||
|
||||
@ -20,6 +20,7 @@ export type CronTask = {
|
||||
nextRunAt?: number
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
providerId?: string | null
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
@ -35,6 +36,7 @@ export type CreateTaskInput = {
|
||||
permanent?: boolean
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
providerId?: string | null
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
|
||||
@ -9,11 +9,14 @@ import {
|
||||
resolveCronProjectRoot,
|
||||
} from '../services/cronScheduler.js'
|
||||
import { CronService } from '../services/cronService.js'
|
||||
import { ProviderService } from '../services/providerService.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 isWindows = process.platform === 'win32'
|
||||
const unixOnly = isWindows ? it.skip : it
|
||||
@ -62,6 +65,16 @@ function restoreEnv(): void {
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
describe('cron scheduler launcher resolution', () => {
|
||||
@ -198,4 +211,85 @@ describe('cron scheduler launcher resolution', () => {
|
||||
.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'
|
||||
|
||||
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.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -296,6 +296,8 @@ describe('Scheduled Tasks API', () => {
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'Daily review',
|
||||
recurring: true,
|
||||
model: 'provider-fast',
|
||||
providerId: 'provider-a',
|
||||
}),
|
||||
})
|
||||
const url = new URL(req.url)
|
||||
@ -303,10 +305,14 @@ describe('Scheduled Tasks API', () => {
|
||||
'api',
|
||||
'scheduled-tasks',
|
||||
])
|
||||
const body = (await resp.json()) as { task: { id: string; prompt: string } }
|
||||
const body = (await resp.json()) as {
|
||||
task: { id: string; prompt: string; model?: string; providerId?: string }
|
||||
}
|
||||
expect(resp.status).toBe(201)
|
||||
expect(body.task.id).toBeDefined()
|
||||
expect(body.task.prompt).toBe('Daily review')
|
||||
expect(body.task.model).toBe('provider-fast')
|
||||
expect(body.task.providerId).toBe('provider-a')
|
||||
})
|
||||
|
||||
it('should CRUD a full lifecycle', async () => {
|
||||
|
||||
@ -59,6 +59,7 @@ export async function handleScheduledTasksApi(
|
||||
permanent: body.permanent as boolean | undefined,
|
||||
permissionMode: body.permissionMode as string | undefined,
|
||||
model: body.model as string | undefined,
|
||||
providerId: body.providerId as string | null | undefined,
|
||||
folderPath: body.folderPath as string | undefined,
|
||||
useWorktree: body.useWorktree as boolean | undefined,
|
||||
notification: body.notification as CronTask['notification'],
|
||||
|
||||
@ -8,13 +8,15 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
import { CronService, type CronTask } from './cronService.js'
|
||||
import { SessionService } from './sessionService.js'
|
||||
import { sendTaskNotification } from './notificationService.js'
|
||||
import { ProviderService } from './providerService.js'
|
||||
import { isProviderManagedEnvVar } from '../../utils/managedEnvConstants.js'
|
||||
import {
|
||||
buildClaudeCliArgs,
|
||||
resolveClaudeCliLauncher,
|
||||
@ -337,6 +339,7 @@ export class CronScheduler {
|
||||
private lastFiredMinuteKey = new Map<string, string>()
|
||||
private cronService: CronService
|
||||
private sessionService: SessionService
|
||||
private providerService = new ProviderService()
|
||||
|
||||
constructor(cronService?: CronService) {
|
||||
this.cronService = cronService || new CronService()
|
||||
@ -499,8 +502,10 @@ export class CronScheduler {
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
...(sessionId ? ['--session-id', sessionId] : []),
|
||||
...this.getRuntimeArgs(task),
|
||||
])
|
||||
|
||||
const childEnv = await this.buildTaskChildEnv(workDir, task)
|
||||
const proc = Bun.spawn(
|
||||
cliArgs,
|
||||
{
|
||||
@ -508,11 +513,7 @@ export class CronScheduler {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: workDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CALLER_DIR: workDir,
|
||||
PWD: workDir,
|
||||
},
|
||||
env: childEnv,
|
||||
},
|
||||
)
|
||||
|
||||
@ -630,6 +631,127 @@ export class CronScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private getRuntimeArgs(task: CronTask): string[] {
|
||||
const model = task.model?.trim()
|
||||
return model ? ['--model', model] : []
|
||||
}
|
||||
|
||||
private async buildTaskChildEnv(
|
||||
workDir: string,
|
||||
task: CronTask,
|
||||
): Promise<Record<string, string | undefined>> {
|
||||
const cleanEnv = { ...process.env }
|
||||
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
if (this.shouldStripInheritedProviderEnv(task.providerId)) {
|
||||
for (const key of Object.keys(cleanEnv)) {
|
||||
if (isProviderManagedEnvVar(key)) {
|
||||
delete cleanEnv[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const explicitProviderEnv =
|
||||
typeof task.providerId === 'string'
|
||||
? await this.providerService.getProviderRuntimeEnv(task.providerId)
|
||||
: null
|
||||
if (explicitProviderEnv && task.model?.trim()) {
|
||||
explicitProviderEnv.ANTHROPIC_MODEL = task.model.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
...cleanEnv,
|
||||
CLAUDE_CODE_ENABLE_TASKS: '1',
|
||||
CALLER_DIR: workDir,
|
||||
PWD: workDir,
|
||||
CC_HAHA_SKIP_DOTENV: '1',
|
||||
...(explicitProviderEnv
|
||||
? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' }
|
||||
: {}),
|
||||
...(explicitProviderEnv ?? {}),
|
||||
...(this.shouldMarkManagedOAuth(task.providerId)
|
||||
? await this.buildOfficialOAuthEnv()
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private shouldStripInheritedProviderEnv(providerId?: string | null): boolean {
|
||||
if (providerId !== undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ccHahaDir = path.join(this.getConfigDir(), 'cc-haha')
|
||||
if (existsSync(path.join(ccHahaDir, 'providers.json'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(path.join(ccHahaDir, 'settings.json'), 'utf-8')
|
||||
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
|
||||
const env = parsed.env ?? {}
|
||||
return Object.entries(env).some(
|
||||
([key, value]) =>
|
||||
isProviderManagedEnvVar(key) &&
|
||||
typeof value === 'string' &&
|
||||
value.trim().length > 0,
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private shouldMarkManagedOAuth(providerId?: string | null): boolean {
|
||||
if (providerId === null) {
|
||||
return true
|
||||
}
|
||||
if (typeof providerId === 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(
|
||||
path.join(this.getConfigDir(), 'cc-haha', 'settings.json'),
|
||||
'utf-8',
|
||||
)
|
||||
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
|
||||
const env = parsed.env ?? {}
|
||||
const hasProviderEnv = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
].some(
|
||||
(key) =>
|
||||
typeof env[key] === 'string' && env[key]!.trim().length > 0,
|
||||
)
|
||||
return !hasProviderEnv
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private async buildOfficialOAuthEnv(): Promise<Record<string, string>> {
|
||||
const env: Record<string, string> = {
|
||||
CLAUDE_CODE_ENTRYPOINT: 'claude-desktop',
|
||||
}
|
||||
try {
|
||||
const { hahaOAuthService } = await import('./hahaOAuthService.js')
|
||||
const token = await hahaOAuthService.ensureFreshAccessToken()
|
||||
if (token) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = token
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[cronScheduler] ensureFreshAccessToken failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -29,6 +29,7 @@ export type CronTask = {
|
||||
permanent?: boolean
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
providerId?: string | null
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
|
||||
@ -76,6 +76,8 @@ export type CronTask = {
|
||||
folder?: string
|
||||
/** Model to use (e.g. "claude-opus-4-7", "claude-sonnet-4-6"). */
|
||||
model?: string
|
||||
/** Desktop provider ID for the selected model; null means official. */
|
||||
providerId?: string | null
|
||||
/** Permission mode: "ask" | "auto-accept" | "plan" | "bypass". */
|
||||
permissionMode?: string
|
||||
/** Whether to use git worktree for execution. */
|
||||
@ -92,6 +94,7 @@ export type CronTaskMeta = {
|
||||
description?: string
|
||||
folder?: string
|
||||
model?: string
|
||||
providerId?: string | null
|
||||
permissionMode?: string
|
||||
worktree?: boolean
|
||||
frequency?: string
|
||||
@ -169,6 +172,9 @@ export async function readCronTasks(dir?: string): Promise<CronTask[]> {
|
||||
: {}),
|
||||
...(typeof t.folder === 'string' ? { folder: t.folder } : {}),
|
||||
...(typeof t.model === 'string' ? { model: t.model } : {}),
|
||||
...(typeof t.providerId === 'string' || t.providerId === null
|
||||
? { providerId: t.providerId }
|
||||
: {}),
|
||||
...(typeof t.permissionMode === 'string'
|
||||
? { permissionMode: t.permissionMode }
|
||||
: {}),
|
||||
@ -254,6 +260,9 @@ export async function addCronTask(
|
||||
...(meta?.description ? { description: meta.description } : {}),
|
||||
...(meta?.folder ? { folder: meta.folder } : {}),
|
||||
...(meta?.model ? { model: meta.model } : {}),
|
||||
...(typeof meta?.providerId === 'string' || meta?.providerId === null
|
||||
? { providerId: meta.providerId }
|
||||
: {}),
|
||||
...(meta?.permissionMode
|
||||
? { permissionMode: meta.permissionMode }
|
||||
: {}),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user