mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 = {
|
type Props = {
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (modelId: string) => void
|
onChange?: (modelId: string) => void
|
||||||
|
runtimeSelection?: RuntimeSelection
|
||||||
|
onRuntimeSelectionChange?: (selection: RuntimeSelection) => void
|
||||||
runtimeKey?: string
|
runtimeKey?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
@ -104,6 +106,8 @@ function resolveDefaultRuntimeSelection(
|
|||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
runtimeSelection: controlledRuntimeSelection,
|
||||||
|
onRuntimeSelectionChange,
|
||||||
runtimeKey,
|
runtimeKey,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
@ -138,7 +142,9 @@ export function ModelSelector({
|
|||||||
]
|
]
|
||||||
|
|
||||||
const isControlled = value !== undefined
|
const isControlled = value !== undefined
|
||||||
const isRuntimeScoped = !isControlled && runtimeKey !== undefined
|
const isRuntimeScoped =
|
||||||
|
!isControlled &&
|
||||||
|
(runtimeKey !== undefined || onRuntimeSelectionChange !== undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
|
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
|
||||||
@ -188,7 +194,7 @@ export function ModelSelector({
|
|||||||
: storeModel
|
: storeModel
|
||||||
|
|
||||||
const activeRuntimeSelection = isRuntimeScoped
|
const activeRuntimeSelection = isRuntimeScoped
|
||||||
? runtimeSelection ?? resolveDefaultRuntimeSelection(
|
? controlledRuntimeSelection ?? runtimeSelection ?? resolveDefaultRuntimeSelection(
|
||||||
activeId,
|
activeId,
|
||||||
activeProviderName,
|
activeProviderName,
|
||||||
providers,
|
providers,
|
||||||
@ -218,10 +224,12 @@ export function ModelSelector({
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
const handleRuntimeSelect = (selection: RuntimeSelection) => {
|
const handleRuntimeSelect = (selection: RuntimeSelection) => {
|
||||||
if (!runtimeKey) return
|
onRuntimeSelectionChange?.(selection)
|
||||||
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
|
if (runtimeKey) {
|
||||||
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
|
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
|
||||||
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
|
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
|
||||||
|
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setOpen(false)
|
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 [frequency, setFrequency] = useState<FrequencyKey>(parsed?.frequency || 'daily')
|
||||||
const [time, setTime] = useState(parsed?.time || '09:00')
|
const [time, setTime] = useState(parsed?.time || '09:00')
|
||||||
const [model, setModel] = useState(editTask?.model || '')
|
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 [permissionMode, setPermissionMode] = useState<PermissionMode>((editTask?.permissionMode as PermissionMode) || 'default')
|
||||||
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
||||||
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
||||||
@ -132,6 +133,7 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
|||||||
cron: cronValue,
|
cron: cronValue,
|
||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
model: model || undefined,
|
model: model || undefined,
|
||||||
|
providerId,
|
||||||
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
||||||
folderPath: folderPath.trim() || undefined,
|
folderPath: folderPath.trim() || undefined,
|
||||||
useWorktree: useWorktree || undefined,
|
useWorktree: useWorktree || undefined,
|
||||||
@ -202,6 +204,8 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
|||||||
onPermissionModeChange={setPermissionMode}
|
onPermissionModeChange={setPermissionMode}
|
||||||
modelId={model}
|
modelId={model}
|
||||||
onModelChange={setModel}
|
onModelChange={setModel}
|
||||||
|
providerId={providerId}
|
||||||
|
onProviderIdChange={setProviderId}
|
||||||
folderPath={folderPath}
|
folderPath={folderPath}
|
||||||
onFolderPathChange={setFolderPath}
|
onFolderPathChange={setFolderPath}
|
||||||
useWorktree={useWorktree}
|
useWorktree={useWorktree}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ type Props = {
|
|||||||
|
|
||||||
modelId: string
|
modelId: string
|
||||||
onModelChange: (modelId: string) => void
|
onModelChange: (modelId: string) => void
|
||||||
|
providerId?: string | null
|
||||||
|
onProviderIdChange: (providerId: string | null) => void
|
||||||
|
|
||||||
folderPath: string
|
folderPath: string
|
||||||
onFolderPathChange: (path: string) => void
|
onFolderPathChange: (path: string) => void
|
||||||
@ -30,6 +32,8 @@ export function PromptEditor({
|
|||||||
onPermissionModeChange,
|
onPermissionModeChange,
|
||||||
modelId,
|
modelId,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
|
providerId,
|
||||||
|
onProviderIdChange,
|
||||||
folderPath,
|
folderPath,
|
||||||
onFolderPathChange,
|
onFolderPathChange,
|
||||||
useWorktree: _useWorktree,
|
useWorktree: _useWorktree,
|
||||||
@ -53,7 +57,13 @@ export function PromptEditor({
|
|||||||
{/* Row 1: Permission + Model selectors */}
|
{/* Row 1: Permission + Model selectors */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} workDir={folderPath || undefined} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Folder picker */}
|
{/* Row 2: Folder picker */}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export type CronTask = {
|
|||||||
nextRunAt?: number
|
nextRunAt?: number
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
providerId?: string | null
|
||||||
folderPath?: string
|
folderPath?: string
|
||||||
useWorktree?: boolean
|
useWorktree?: boolean
|
||||||
notification?: TaskNotificationConfig
|
notification?: TaskNotificationConfig
|
||||||
@ -35,6 +36,7 @@ export type CreateTaskInput = {
|
|||||||
permanent?: boolean
|
permanent?: boolean
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
providerId?: string | null
|
||||||
folderPath?: string
|
folderPath?: string
|
||||||
useWorktree?: boolean
|
useWorktree?: boolean
|
||||||
notification?: TaskNotificationConfig
|
notification?: TaskNotificationConfig
|
||||||
|
|||||||
@ -9,11 +9,14 @@ import {
|
|||||||
resolveCronProjectRoot,
|
resolveCronProjectRoot,
|
||||||
} from '../services/cronScheduler.js'
|
} from '../services/cronScheduler.js'
|
||||||
import { CronService } from '../services/cronService.js'
|
import { CronService } from '../services/cronService.js'
|
||||||
|
import { ProviderService } from '../services/providerService.js'
|
||||||
|
|
||||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
const originalPath = process.env.PATH
|
const originalPath = process.env.PATH
|
||||||
const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH
|
const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH
|
||||||
const originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
|
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 isWindows = process.platform === 'win32'
|
||||||
const unixOnly = isWindows ? it.skip : it
|
const unixOnly = isWindows ? it.skip : it
|
||||||
@ -62,6 +65,16 @@ function restoreEnv(): void {
|
|||||||
} else {
|
} else {
|
||||||
delete process.env.CLAUDE_APP_ROOT
|
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', () => {
|
describe('cron scheduler launcher resolution', () => {
|
||||||
@ -198,4 +211,85 @@ describe('cron scheduler launcher resolution', () => {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
expect(bunWasCalled).toBe(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 * * *',
|
cron: '0 9 * * *',
|
||||||
prompt: 'Daily review',
|
prompt: 'Daily review',
|
||||||
recurring: true,
|
recurring: true,
|
||||||
|
model: 'provider-fast',
|
||||||
|
providerId: 'provider-a',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@ -303,10 +305,14 @@ describe('Scheduled Tasks API', () => {
|
|||||||
'api',
|
'api',
|
||||||
'scheduled-tasks',
|
'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(resp.status).toBe(201)
|
||||||
expect(body.task.id).toBeDefined()
|
expect(body.task.id).toBeDefined()
|
||||||
expect(body.task.prompt).toBe('Daily review')
|
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 () => {
|
it('should CRUD a full lifecycle', async () => {
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export async function handleScheduledTasksApi(
|
|||||||
permanent: body.permanent as boolean | undefined,
|
permanent: body.permanent as boolean | undefined,
|
||||||
permissionMode: body.permissionMode as string | undefined,
|
permissionMode: body.permissionMode as string | undefined,
|
||||||
model: body.model as string | undefined,
|
model: body.model as string | undefined,
|
||||||
|
providerId: body.providerId as string | null | undefined,
|
||||||
folderPath: body.folderPath as string | undefined,
|
folderPath: body.folderPath as string | undefined,
|
||||||
useWorktree: body.useWorktree as boolean | undefined,
|
useWorktree: body.useWorktree as boolean | undefined,
|
||||||
notification: body.notification as CronTask['notification'],
|
notification: body.notification as CronTask['notification'],
|
||||||
|
|||||||
@ -8,13 +8,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs/promises'
|
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 path from 'path'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import { CronService, type CronTask } from './cronService.js'
|
import { CronService, type CronTask } from './cronService.js'
|
||||||
import { SessionService } from './sessionService.js'
|
import { SessionService } from './sessionService.js'
|
||||||
import { sendTaskNotification } from './notificationService.js'
|
import { sendTaskNotification } from './notificationService.js'
|
||||||
|
import { ProviderService } from './providerService.js'
|
||||||
|
import { isProviderManagedEnvVar } from '../../utils/managedEnvConstants.js'
|
||||||
import {
|
import {
|
||||||
buildClaudeCliArgs,
|
buildClaudeCliArgs,
|
||||||
resolveClaudeCliLauncher,
|
resolveClaudeCliLauncher,
|
||||||
@ -337,6 +339,7 @@ export class CronScheduler {
|
|||||||
private lastFiredMinuteKey = new Map<string, string>()
|
private lastFiredMinuteKey = new Map<string, string>()
|
||||||
private cronService: CronService
|
private cronService: CronService
|
||||||
private sessionService: SessionService
|
private sessionService: SessionService
|
||||||
|
private providerService = new ProviderService()
|
||||||
|
|
||||||
constructor(cronService?: CronService) {
|
constructor(cronService?: CronService) {
|
||||||
this.cronService = cronService || new CronService()
|
this.cronService = cronService || new CronService()
|
||||||
@ -499,8 +502,10 @@ export class CronScheduler {
|
|||||||
'--output-format',
|
'--output-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
...(sessionId ? ['--session-id', sessionId] : []),
|
...(sessionId ? ['--session-id', sessionId] : []),
|
||||||
|
...this.getRuntimeArgs(task),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const childEnv = await this.buildTaskChildEnv(workDir, task)
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
cliArgs,
|
cliArgs,
|
||||||
{
|
{
|
||||||
@ -508,11 +513,7 @@ export class CronScheduler {
|
|||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
env: {
|
env: childEnv,
|
||||||
...process.env,
|
|
||||||
CALLER_DIR: workDir,
|
|
||||||
PWD: workDir,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -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 ───────────────────────────────────────────────────────────────
|
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export type CronTask = {
|
|||||||
permanent?: boolean
|
permanent?: boolean
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
providerId?: string | null
|
||||||
folderPath?: string
|
folderPath?: string
|
||||||
useWorktree?: boolean
|
useWorktree?: boolean
|
||||||
notification?: TaskNotificationConfig
|
notification?: TaskNotificationConfig
|
||||||
|
|||||||
@ -76,6 +76,8 @@ export type CronTask = {
|
|||||||
folder?: string
|
folder?: string
|
||||||
/** Model to use (e.g. "claude-opus-4-7", "claude-sonnet-4-6"). */
|
/** Model to use (e.g. "claude-opus-4-7", "claude-sonnet-4-6"). */
|
||||||
model?: string
|
model?: string
|
||||||
|
/** Desktop provider ID for the selected model; null means official. */
|
||||||
|
providerId?: string | null
|
||||||
/** Permission mode: "ask" | "auto-accept" | "plan" | "bypass". */
|
/** Permission mode: "ask" | "auto-accept" | "plan" | "bypass". */
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
/** Whether to use git worktree for execution. */
|
/** Whether to use git worktree for execution. */
|
||||||
@ -92,6 +94,7 @@ export type CronTaskMeta = {
|
|||||||
description?: string
|
description?: string
|
||||||
folder?: string
|
folder?: string
|
||||||
model?: string
|
model?: string
|
||||||
|
providerId?: string | null
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
worktree?: boolean
|
worktree?: boolean
|
||||||
frequency?: string
|
frequency?: string
|
||||||
@ -169,6 +172,9 @@ export async function readCronTasks(dir?: string): Promise<CronTask[]> {
|
|||||||
: {}),
|
: {}),
|
||||||
...(typeof t.folder === 'string' ? { folder: t.folder } : {}),
|
...(typeof t.folder === 'string' ? { folder: t.folder } : {}),
|
||||||
...(typeof t.model === 'string' ? { model: t.model } : {}),
|
...(typeof t.model === 'string' ? { model: t.model } : {}),
|
||||||
|
...(typeof t.providerId === 'string' || t.providerId === null
|
||||||
|
? { providerId: t.providerId }
|
||||||
|
: {}),
|
||||||
...(typeof t.permissionMode === 'string'
|
...(typeof t.permissionMode === 'string'
|
||||||
? { permissionMode: t.permissionMode }
|
? { permissionMode: t.permissionMode }
|
||||||
: {}),
|
: {}),
|
||||||
@ -254,6 +260,9 @@ export async function addCronTask(
|
|||||||
...(meta?.description ? { description: meta.description } : {}),
|
...(meta?.description ? { description: meta.description } : {}),
|
||||||
...(meta?.folder ? { folder: meta.folder } : {}),
|
...(meta?.folder ? { folder: meta.folder } : {}),
|
||||||
...(meta?.model ? { model: meta.model } : {}),
|
...(meta?.model ? { model: meta.model } : {}),
|
||||||
|
...(typeof meta?.providerId === 'string' || meta?.providerId === null
|
||||||
|
? { providerId: meta.providerId }
|
||||||
|
: {}),
|
||||||
...(meta?.permissionMode
|
...(meta?.permissionMode
|
||||||
? { permissionMode: meta.permissionMode }
|
? { permissionMode: meta.permissionMode }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user