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:
程序员阿江(Relakkes) 2026-05-08 09:49:16 +08:00
parent de9d044bdb
commit 7696e21dc2
11 changed files with 364 additions and 14 deletions

View File

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

View 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,
}))
})
})

View File

@ -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}

View File

@ -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 */}

View File

@ -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

View File

@ -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()
})
})

View File

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

View File

@ -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'],

View File

@ -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 ───────────────────────────────────────────────────────────────
/**

View File

@ -29,6 +29,7 @@ export type CronTask = {
permanent?: boolean
permissionMode?: string
model?: string
providerId?: string | null
folderPath?: string
useWorktree?: boolean
notification?: TaskNotificationConfig

View File

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