diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 5d6d651240f0..4eeae27ed5b1 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use tokio::fs; use axum::{ extract::{Path, Query, State}, @@ -9,13 +10,30 @@ use axum::{ use serde::{Deserialize, Serialize}; use crate::routes::errors::ErrorResponse; +use crate::routes::recipe_utils::validate_recipe; use crate::state::AppState; -use goose::scheduler::ScheduledJob; +use goose::recipe::Recipe; +use goose::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob}; + +fn validate_schedule_id(id: &str) -> Result<(), ErrorResponse> { + let is_valid = !id.is_empty() + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' '); + + if !is_valid { + return Err(ErrorResponse::bad_request( + "Schedule name must use only alphanumeric characters, hyphens, underscores, or spaces" + .to_string(), + )); + } + Ok(()) +} #[derive(Deserialize, Serialize, utoipa::ToSchema)] pub struct CreateScheduleRequest { id: String, - recipe_source: String, + recipe: Recipe, cron: String, } @@ -89,11 +107,36 @@ async fn create_schedule( State(state): State>, Json(req): Json, ) -> Result, ErrorResponse> { - let scheduler = state.scheduler(); + let id = req.id.trim().to_string(); + validate_schedule_id(&id)?; + + if req.recipe.check_for_security_warnings() { + return Err(ErrorResponse::bad_request( + "This recipe contains hidden characters that could be malicious. Please remove them before trying to save.".to_string(), + )); + } + if let Err(err) = validate_recipe(&req.recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } + let scheduled_recipes_dir = get_default_scheduled_recipes_dir().map_err(|e| { + ErrorResponse::internal(format!("Failed to get scheduled recipes directory: {}", e)) + })?; + + let recipe_path = scheduled_recipes_dir.join(format!("{}.yaml", id)); + let yaml_content = req + .recipe + .to_yaml() + .map_err(|e| ErrorResponse::internal(format!("Failed to convert recipe to YAML: {}", e)))?; + fs::write(&recipe_path, yaml_content) + .await + .map_err(|e| ErrorResponse::internal(format!("Failed to save recipe file: {}", e)))?; let job = ScheduledJob { - id: req.id, - source: req.recipe_source, + id, + source: recipe_path.to_string_lossy().into_owned(), cron: req.cron, last_run: None, currently_running: false, @@ -101,8 +144,10 @@ async fn create_schedule( current_session_id: None, process_start_time: None, }; + + let scheduler = state.scheduler(); scheduler - .add_scheduled_job(job.clone(), true) + .add_scheduled_job(job.clone(), false) .await .map_err(|e| match e { goose::scheduler::SchedulerError::CronParseError(msg) => { @@ -117,6 +162,7 @@ async fn create_schedule( }, _ => ErrorResponse::internal(format!("Error creating schedule: {}", e)), })?; + Ok(Json(job)) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1b56e711b402..bb3f38aa9de7 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3437,7 +3437,7 @@ "type": "object", "required": [ "id", - "recipe_source", + "recipe", "cron" ], "properties": { @@ -3447,8 +3447,8 @@ "id": { "type": "string" }, - "recipe_source": { - "type": "string" + "recipe": { + "$ref": "#/components/schemas/Recipe" } } }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 2bc51d6010af..7a97d4130bd9 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -135,7 +135,7 @@ export type CreateRecipeResponse = { export type CreateScheduleRequest = { cron: string; id: string; - recipe_source: string; + recipe: Recipe; }; /** diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 21c36b384a39..93ff140f1195 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -4,12 +4,11 @@ import { z } from 'zod'; import { Download } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -import { Recipe, decodeRecipe } from '../../recipe'; +import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { getRecipeJsonSchema } from '../../recipe/validation'; import { saveRecipe } from '../../recipe/recipe_management'; -import { parseRecipe } from '../../api'; import { errorMessage } from '../../utils/conversionUtils'; interface ImportRecipeFormProps { @@ -46,54 +45,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR useEscapeKey(isOpen, onClose); - const parseDeeplink = async (deeplink: string): Promise => { - try { - const cleanLink = deeplink.trim(); - - if (!cleanLink.startsWith('goose://recipe?config=')) { - throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...'); - } - - const recipeEncoded = cleanLink.replace('goose://recipe?config=', ''); - - if (!recipeEncoded) { - throw new Error('No recipe configuration found in deeplink'); - } - const recipe = await decodeRecipe(recipeEncoded); - - if (!recipe.title || !recipe.description) { - throw new Error('Recipe is missing required fields (title, description)'); - } - - if (!recipe.instructions && !recipe.prompt) { - throw new Error('Recipe must have either instructions or prompt'); - } - - return recipe; - } catch (error) { - console.error('Failed to parse deeplink:', error); - return null; - } - }; - - const parseRecipeFromFile = async (fileContent: string): Promise => { - try { - let response = await parseRecipe({ - body: { - content: fileContent, - }, - throwOnError: true, - }); - return response.data.recipe; - } catch (error) { - let error_message = 'unknown error'; - if (typeof error === 'object' && error !== null && 'message' in error) { - error_message = error.message as string; - } - throw new Error(error_message); - } - }; - const importRecipeForm = useForm({ defaultValues: { deeplink: '', diff --git a/ui/desktop/src/components/schedule/ScheduleModal.tsx b/ui/desktop/src/components/schedule/ScheduleModal.tsx index fccda47b1fff..f1019d9e39bf 100644 --- a/ui/desktop/src/components/schedule/ScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/ScheduleModal.tsx @@ -4,16 +4,14 @@ import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { ScheduledJob } from '../../schedule'; import { CronPicker } from './CronPicker'; -import { Recipe, decodeRecipe } from '../../recipe'; +import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe'; import { getStorageDirectory } from '../../recipe/recipe_management'; import ClockIcon from '../../assets/clock-icon.svg'; -import * as yaml from 'yaml'; export interface NewSchedulePayload { id: string; - recipe_source: string; + recipe: Recipe; cron: string; - execution_mode?: string; } interface ScheduleModalProps { @@ -28,170 +26,6 @@ interface ScheduleModalProps { type SourceType = 'file' | 'deeplink'; -interface CleanExtension { - name: string; - type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http' | 'platform'; - cmd?: string; - args?: string[]; - uri?: string; - display_name?: string; - tools?: unknown[]; - instructions?: string; - env_keys?: string[]; - timeout?: number; - description?: string; - bundled?: boolean; -} - -interface CleanRecipe { - title: string; - description: string; - instructions?: string; - prompt?: string; - activities?: string[]; - extensions?: CleanExtension[]; - author?: { - contact?: string; - metadata?: string; - }; - schedule?: { - window_title?: string; - working_directory?: string; - }; -} - -async function parseDeepLink(deepLink: string): Promise { - try { - const url = new URL(deepLink); - if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { - return null; - } - - const recipeParam = url.searchParams.get('config'); - if (!recipeParam) { - return null; - } - - return await decodeRecipe(recipeParam); - } catch (error) { - console.error('Failed to parse deep link:', error); - return null; - } -} - -function recipeToYaml(recipe: Recipe): string { - const cleanRecipe: CleanRecipe = { - title: recipe.title, - description: recipe.description, - }; - - if (recipe.instructions) { - cleanRecipe.instructions = recipe.instructions; - } - - if (recipe.prompt) { - cleanRecipe.prompt = recipe.prompt; - } - - if (recipe.activities && recipe.activities.length > 0) { - cleanRecipe.activities = recipe.activities; - } - - if (recipe.extensions && recipe.extensions.length > 0) { - cleanRecipe.extensions = recipe.extensions.map((ext) => { - const cleanExt: CleanExtension = { - name: ext.name, - type: 'builtin', - }; - - if ('type' in ext && ext.type) { - cleanExt.type = ext.type as CleanExtension['type']; - - const extAny = ext as Record; - - if (ext.type === 'sse' && extAny.uri) { - cleanExt.uri = extAny.uri as string; - } else if (ext.type === 'streamable_http' && extAny.uri) { - cleanExt.uri = extAny.uri as string; - } else if (ext.type === 'stdio') { - if (extAny.cmd) { - cleanExt.cmd = extAny.cmd as string; - } - if (extAny.args) { - cleanExt.args = extAny.args as string[]; - } - } else if ((ext.type === 'builtin' || ext.type === 'platform') && extAny.display_name) { - cleanExt.display_name = extAny.display_name as string; - } - - if ((ext.type as string) === 'frontend') { - if (extAny.tools) { - cleanExt.tools = extAny.tools as unknown[]; - } - if (extAny.instructions) { - cleanExt.instructions = extAny.instructions as string; - } - } - } else { - const extAny = ext as Record; - - if (extAny.cmd) { - cleanExt.type = 'stdio'; - cleanExt.cmd = extAny.cmd as string; - if (extAny.args) { - cleanExt.args = extAny.args as string[]; - } - } else if (extAny.command) { - cleanExt.type = 'stdio'; - cleanExt.cmd = extAny.command as string; - } else if (extAny.uri) { - cleanExt.type = 'streamable_http'; - cleanExt.uri = extAny.uri as string; - } else if (extAny.tools) { - cleanExt.type = 'frontend'; - cleanExt.tools = extAny.tools as unknown[]; - if (extAny.instructions) { - cleanExt.instructions = extAny.instructions as string; - } - } else { - cleanExt.type = 'builtin'; - } - } - - if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) { - cleanExt.env_keys = ext.env_keys; - } - - if ('timeout' in ext && ext.timeout) { - cleanExt.timeout = ext.timeout as number; - } - - if ('description' in ext && ext.description) { - cleanExt.description = ext.description as string; - } - - if ('bundled' in ext && ext.bundled !== undefined) { - cleanExt.bundled = ext.bundled as boolean; - } - - return cleanExt; - }); - } - - if (recipe.author) { - cleanRecipe.author = { - contact: recipe.author.contact || undefined, - metadata: recipe.author.metadata || undefined, - }; - } - - cleanRecipe.schedule = { - window_title: `${recipe.title} - Scheduled`, - }; - - return yaml.stringify(cleanRecipe); -} - const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1'; export const ScheduleModal: React.FC = ({ @@ -214,33 +48,29 @@ export const ScheduleModal: React.FC = ({ const [internalValidationError, setInternalValidationError] = useState(null); const [isValid, setIsValid] = useState(true); + const setScheduleIdFromTitle = (title: string) => { + const cleanId = title + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + setScheduleId(cleanId); + }; + const handleDeepLinkChange = useCallback(async (value: string) => { setDeepLinkInput(value); setInternalValidationError(null); if (value.trim()) { try { - const recipe = await parseDeepLink(value.trim()); - if (recipe) { - setParsedRecipe(recipe); - if (recipe.title) { - const cleanId = recipe.title - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-'); - setScheduleId(cleanId); - } - } else { - setParsedRecipe(null); - setInternalValidationError( - 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' - ); + const recipe = await parseDeeplink(value.trim()); + if (!recipe) throw new Error(); + setParsedRecipe(recipe); + if (recipe.title) { + setScheduleIdFromTitle(recipe.title); } } catch { setParsedRecipe(null); - setInternalValidationError( - 'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.' - ); + setInternalValidationError('Invalid deep link. Please use a goose://recipe link.'); } } else { setParsedRecipe(null); @@ -275,6 +105,26 @@ export const ScheduleModal: React.FC = ({ if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { setRecipeSourcePath(filePath); setInternalValidationError(null); + + try { + const fileResponse = await window.electron.readFile(filePath); + if (!fileResponse.found || fileResponse.error) { + throw new Error('Failed to read the selected file.'); + } + const recipe = await parseRecipeFromFile(fileResponse.file); + if (!recipe) { + throw new Error('Failed to parse recipe from file.'); + } + setParsedRecipe(recipe); + if (recipe.title) { + setScheduleIdFromTitle(recipe.title); + } + } catch (e) { + setParsedRecipe(null); + setInternalValidationError( + e instanceof Error ? e.message : 'Failed to parse recipe from file.' + ); + } } else { setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)'); } @@ -295,47 +145,14 @@ export const ScheduleModal: React.FC = ({ return; } - let finalRecipeSource = ''; - - if (sourceType === 'file') { - if (!recipeSourcePath) { - setInternalValidationError('Recipe source file is required.'); - return; - } - finalRecipeSource = recipeSourcePath; - } else if (sourceType === 'deeplink') { - if (!deepLinkInput.trim()) { - setInternalValidationError('Deep link is required.'); - return; - } - if (!parsedRecipe) { - setInternalValidationError('Invalid deep link. Please check the format.'); - return; - } - - try { - const yamlContent = recipeToYaml(parsedRecipe); - const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; - const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; - const tempFilePath = `${tempDir}/${tempFileName}`; - - const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); - if (!writeSuccess) { - setInternalValidationError('Failed to create temporary recipe file.'); - return; - } - - finalRecipeSource = tempFilePath; - } catch (error) { - console.error('Failed to convert recipe to YAML:', error); - setInternalValidationError('Failed to process the recipe from deep link.'); - return; - } + if (!parsedRecipe) { + setInternalValidationError('Please provide a valid recipe source.'); + return; } const newSchedulePayload: NewSchedulePayload = { id: scheduleId.trim(), - recipe_source: finalRecipeSource, + recipe: parsedRecipe, cron: cronExpression, }; @@ -443,7 +260,7 @@ export const ScheduleModal: React.FC = ({ type="text" value={deepLinkInput} onChange={(e) => handleDeepLinkChange(e.target.value)} - placeholder="Paste goose://bot or goose://recipe link here..." + placeholder="Paste goose://recipe link here..." className="rounded-full" /> {parsedRecipe && ( diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index a4a0d95e6d45..03a33985f95b 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -2,6 +2,7 @@ import { encodeRecipe as apiEncodeRecipe, decodeRecipe as apiDecodeRecipe, scanRecipe as apiScanRecipe, + parseRecipe as apiParseRecipe, } from '../api'; import type { RecipeParameter } from '../api'; @@ -84,3 +85,54 @@ export function stripEmptyExtensions(recipe: Recipe): Recipe { } return recipe; } + +export async function parseRecipeFromFile(fileContent: string): Promise { + try { + const response = await apiParseRecipe({ + body: { content: fileContent }, + throwOnError: true, + }); + + if (!response.data?.recipe) { + throw new Error('No recipe returned from API'); + } + + return response.data.recipe as Recipe; + } catch (error) { + let errorMessage = 'unknown error'; + if (typeof error === 'object' && error !== null && 'message' in error) { + errorMessage = error.message as string; + } + throw new Error(errorMessage); + } +} + +export async function parseDeeplink(deeplink: string): Promise { + try { + const cleanLink = deeplink.trim(); + + if (!cleanLink.startsWith('goose://recipe?config=')) { + throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...'); + } + + const recipeEncoded = cleanLink.replace('goose://recipe?config=', ''); + + if (!recipeEncoded) { + throw new Error('No recipe configuration found in deeplink'); + } + const recipe = await decodeRecipe(recipeEncoded); + + if (!recipe.title || !recipe.description) { + throw new Error('Recipe is missing required fields (title, description)'); + } + + if (!recipe.instructions && !recipe.prompt) { + throw new Error('Recipe must have either instructions or prompt'); + } + + return recipe; + } catch (error) { + console.error('Failed to parse deeplink:', error); + return null; + } +} diff --git a/ui/desktop/src/schedule.ts b/ui/desktop/src/schedule.ts index 460822e2768c..b5a72e0e979a 100644 --- a/ui/desktop/src/schedule.ts +++ b/ui/desktop/src/schedule.ts @@ -11,6 +11,7 @@ import { inspectRunningJob as apiInspectRunningJob, SessionDisplayInfo, } from './api'; +import type { Recipe } from './api'; export interface ScheduledJob { id: string; @@ -54,21 +55,15 @@ export async function listSchedules(): Promise { export async function createSchedule(request: { id: string; - recipe_source: string; + recipe: Recipe; cron: string; - execution_mode?: string; }): Promise { - try { - const response = await apiCreateSchedule({ body: request }); - if (response && response.data) { - return response.data as ScheduledJob; - } - console.error('Unexpected response format from apiCreateSchedule', response); - throw new Error('Failed to create schedule: Unexpected response format'); - } catch (error) { - console.error('Error creating schedule:', error); - throw error; + const response = await apiCreateSchedule({ body: request }); + if (response.data) { + return response.data as ScheduledJob; } + const err = response.error as { message?: string } | undefined; + throw new Error(err?.message || 'Failed to create schedule'); } export async function deleteSchedule(id: string): Promise {