From 7696e21dc2248aa3bfd8d257d4eaddaf79b2e7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 09:49:16 +0800 Subject: [PATCH] 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. --- .../src/components/controls/ModelSelector.tsx | 20 ++- .../components/tasks/NewTaskModal.test.tsx | 93 ++++++++++++ desktop/src/components/tasks/NewTaskModal.tsx | 4 + desktop/src/components/tasks/PromptEditor.tsx | 12 +- desktop/src/types/task.ts | 2 + .../__tests__/cron-scheduler-launcher.test.ts | 94 ++++++++++++ src/server/__tests__/scheduled-tasks.test.ts | 8 +- src/server/api/scheduled-tasks.ts | 1 + src/server/services/cronScheduler.ts | 134 +++++++++++++++++- src/server/services/cronService.ts | 1 + src/utils/cronTasks.ts | 9 ++ 11 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 desktop/src/components/tasks/NewTaskModal.test.tsx diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 0605575c..806a6501 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -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) } diff --git a/desktop/src/components/tasks/NewTaskModal.test.tsx b/desktop/src/components/tasks/NewTaskModal.test.tsx new file mode 100644 index 00000000..8c24de79 --- /dev/null +++ b/desktop/src/components/tasks/NewTaskModal.test.tsx @@ -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>) + useAdapterStore.setState({ + fetchConfig: vi.fn(async () => {}), + config: {}, + } as Partial>) + 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() + + 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, + })) + }) +}) diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index 16a72e3e..5ce49dec 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -93,6 +93,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) { const [frequency, setFrequency] = useState(parsed?.frequency || 'daily') const [time, setTime] = useState(parsed?.time || '09:00') const [model, setModel] = useState(editTask?.model || '') + const [providerId, setProviderId] = useState(editTask?.providerId) const [permissionMode, setPermissionMode] = useState((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} diff --git a/desktop/src/components/tasks/PromptEditor.tsx b/desktop/src/components/tasks/PromptEditor.tsx index 172470a2..b406751c 100644 --- a/desktop/src/components/tasks/PromptEditor.tsx +++ b/desktop/src/components/tasks/PromptEditor.tsx @@ -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 */}
- + { + onProviderIdChange(selection.providerId) + onModelChange(selection.modelId) + }} + />
{/* Row 2: Folder picker */} diff --git a/desktop/src/types/task.ts b/desktop/src/types/task.ts index f8904da2..f35d21b3 100644 --- a/desktop/src/types/task.ts +++ b/desktop/src/types/task.ts @@ -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 diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 2840b14d..0263752f 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -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() + }) }) diff --git a/src/server/__tests__/scheduled-tasks.test.ts b/src/server/__tests__/scheduled-tasks.test.ts index 8b031981..382cb7ff 100644 --- a/src/server/__tests__/scheduled-tasks.test.ts +++ b/src/server/__tests__/scheduled-tasks.test.ts @@ -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 () => { diff --git a/src/server/api/scheduled-tasks.ts b/src/server/api/scheduled-tasks.ts index 0de64316..07852774 100644 --- a/src/server/api/scheduled-tasks.ts +++ b/src/server/api/scheduled-tasks.ts @@ -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'], diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index b05df2fd..e7d9bf0f 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -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() 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> { + 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 } + 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 } + 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> { + const env: Record = { + 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 ─────────────────────────────────────────────────────────────── /** diff --git a/src/server/services/cronService.ts b/src/server/services/cronService.ts index 0e147f5d..16767c51 100644 --- a/src/server/services/cronService.ts +++ b/src/server/services/cronService.ts @@ -29,6 +29,7 @@ export type CronTask = { permanent?: boolean permissionMode?: string model?: string + providerId?: string | null folderPath?: string useWorktree?: boolean notification?: TaskNotificationConfig diff --git a/src/utils/cronTasks.ts b/src/utils/cronTasks.ts index 1b871fe9..f3bcf5c0 100644 --- a/src/utils/cronTasks.ts +++ b/src/utils/cronTasks.ts @@ -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 { : {}), ...(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 } : {}),