Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ http = "1.0"
webbrowser = "1.0"

indicatif = "0.17.11"
urlencoding = "2"

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
Expand Down
29 changes: 18 additions & 11 deletions crates/goose-cli/src/commands/recipe.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use anyhow::Result;
use base64::Engine;
use console::style;
use serde_json;

use crate::recipes::github_recipe::RecipeSource;
use crate::recipes::recipe::load_recipe_for_validation;
use crate::recipes::search_recipe::list_available_recipes;
use goose::recipe_deeplink;

/// Validates a recipe file
///
Expand Down Expand Up @@ -42,21 +41,26 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
// Load the recipe file first to validate it
match load_recipe_for_validation(recipe_name) {
Ok(recipe) => {
let mut full_url = String::new();
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
Ok(recipe) => match recipe_deeplink::encode(&recipe) {
Ok(encoded) => {
println!(
"{} Generated deeplink for: {}",
style("✓").green().bold(),
recipe.title
);
let url_safe = urlencoding::encode(&deeplink);
full_url = format!("goose://recipe?config={}", url_safe);
let full_url = format!("goose://recipe?config={}", encoded);
println!("{}", full_url);
Ok(full_url)
}
Ok(full_url)
}
Err(err) => {
println!(
"{} Failed to encode recipe: {}",
style("✗").red().bold(),
err
);
Err(anyhow::anyhow!("Failed to encode recipe: {}", err))
}
},
Err(err) => {
println!("{} {}", style("✗").red().bold(), err);
Err(err)
Expand Down Expand Up @@ -185,7 +189,10 @@ response:

let result = handle_deeplink(&recipe_path);
assert!(result.is_ok());
assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIHdpdGggVmFsaWQgSlNPTiBTY2hlbWEiLCJkZXNjcmlwdGlvbiI6IkEgdGVzdCByZWNpcGUgd2l0aCB2YWxpZCBKU09OIHNjaGVtYSIsImluc3RydWN0aW9ucyI6IlRlc3QgaW5zdHJ1Y3Rpb25zIiwicHJvbXB0IjoiVGVzdCBwcm9tcHQgY29udGVudCIsInJlc3BvbnNlIjp7Impzb25fc2NoZW1hIjp7InByb3BlcnRpZXMiOnsiY291bnQiOnsiZGVzY3JpcHRpb24iOiJBIGNvdW50IHZhbHVlIiwidHlwZSI6Im51bWJlciJ9LCJyZXN1bHQiOnsiZGVzY3JpcHRpb24iOiJUaGUgcmVzdWx0IiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicmVzdWx0Il0sInR5cGUiOiJvYmplY3QifX19"));
let url = result.unwrap();
assert!(url.starts_with("goose://recipe?config="));
let encoded_part = url.strip_prefix("goose://recipe?config=").unwrap();
assert!(encoded_part.len() > 0);
}

#[test]
Expand Down
1 change: 0 additions & 1 deletion crates/goose-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
url = "2.5"
urlencoding = "2.1.3"
base64 = "0.21"
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
Expand Down
83 changes: 83 additions & 0 deletions crates/goose-server/src/routes/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::sync::Arc;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::message::Message;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use serde::{Deserialize, Serialize};

use crate::state::AppState;
Expand Down Expand Up @@ -34,6 +35,26 @@ pub struct CreateRecipeResponse {
error: Option<String>,
}

#[derive(Debug, Deserialize)]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this entire recipe thing is not part of our open-api integration somehow. this should be integrated with openapi.rs and then we can autogenerate the ts code. can you check how much work it is to do this the right way? if too much, we can do in a follow up

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the open-api pieces. I can rectifying that in a future pr.

pub struct EncodeRecipeRequest {
recipe: Recipe,
}

#[derive(Debug, Serialize)]
pub struct EncodeRecipeResponse {
deeplink: String,
}

#[derive(Debug, Deserialize)]
pub struct DecodeRecipeRequest {
deeplink: String,
}

#[derive(Debug, Serialize)]
pub struct DecodeRecipeResponse {
recipe: Recipe,
}

/// Create a Recipe configuration from the current state of an agent
async fn create_recipe(
State(state): State<Arc<AppState>>,
Expand Down Expand Up @@ -84,8 +105,70 @@ async fn create_recipe(
}
}

async fn encode_recipe(
Json(request): Json<EncodeRecipeRequest>,
) -> Result<Json<EncodeRecipeResponse>, StatusCode> {
match recipe_deeplink::encode(&request.recipe) {
Ok(encoded) => Ok(Json(EncodeRecipeResponse { deeplink: encoded })),
Err(err) => {
tracing::error!("Failed to encode recipe: {}", err);
Err(StatusCode::BAD_REQUEST)
}
}
}

async fn decode_recipe(
Json(request): Json<DecodeRecipeRequest>,
) -> Result<Json<DecodeRecipeResponse>, StatusCode> {
match recipe_deeplink::decode(&request.deeplink) {
Ok(recipe) => Ok(Json(DecodeRecipeResponse { recipe })),
Err(err) => {
tracing::error!("Failed to decode deeplink: {}", err);
Err(StatusCode::BAD_REQUEST)
}
}
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/recipe/create", post(create_recipe))
.route("/recipes/encode", post(encode_recipe))
.route("/recipes/decode", post(decode_recipe))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move /recipe to /recipes in a future pr

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree

.with_state(state)
}

#[cfg(test)]
mod tests {
use super::*;
use goose::recipe::Recipe;

#[tokio::test]
async fn test_decode_and_encode_recipe() {
let original_recipe = Recipe::builder()
.title("Test Recipe")
.description("A test recipe")
.instructions("Test instructions")
.build()
.unwrap();
let encoded = recipe_deeplink::encode(&original_recipe).unwrap();

let request = DecodeRecipeRequest {
deeplink: encoded.clone(),
};
let response = decode_recipe(Json(request)).await;

assert!(response.is_ok());
let decoded = response.unwrap().0.recipe;
assert_eq!(decoded.title, original_recipe.title);
assert_eq!(decoded.description, original_recipe.description);
assert_eq!(decoded.instructions, original_recipe.instructions);

let encode_request = EncodeRecipeRequest { recipe: decoded };
let encode_response = encode_recipe(Json(encode_request)).await;

assert!(encode_response.is_ok());
let encoded_again = encode_response.unwrap().0.deeplink;
assert!(!encoded_again.is_empty());
assert_eq!(encoded, encoded_again);
}
}
1 change: 1 addition & 0 deletions crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ nanoid = "0.4"
sha2 = "0.10"
base64 = "0.21"
url = "2.5"
urlencoding = "2.1"
axum = "0.8.1"
webbrowser = "0.8"
lazy_static = "1.5.0"
Expand Down
1 change: 1 addition & 0 deletions crates/goose/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod project;
pub mod prompt_template;
pub mod providers;
pub mod recipe;
pub mod recipe_deeplink;
pub mod scheduler;
pub mod scheduler_factory;
pub mod scheduler_trait;
Expand Down
108 changes: 108 additions & 0 deletions crates/goose/src/recipe_deeplink.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use anyhow::Result;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use thiserror::Error;

use crate::recipe::Recipe;

#[derive(Error, Debug)]
pub enum DecodeError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future, it would be nice to distinguish the errors.

#[derive(Error, Debug)]
pub enum DecodeError {
    #[error("Invalid base64 encoding")]
    InvalidBase64,
    #[error("Invalid JSON format: {0}")]
    InvalidJson(String),
    #[error("Invalid recipe format: missing required fields")]
    InvalidRecipe,
    #[error("Unsupported deeplink format")]
    UnsupportedFormat,
}

#[error("All decoding methods failed")]
AllMethodsFailed,
}

pub fn encode(recipe: &Recipe) -> Result<String, serde_json::Error> {
let recipe_json = serde_json::to_string(recipe)?;
let encoded = URL_SAFE_NO_PAD.encode(recipe_json.as_bytes());
Ok(encoded)
}

pub fn decode(link: &str) -> Result<Recipe, DecodeError> {
// Handle the current format: URL-safe Base64 without padding.
if let Ok(decoded_bytes) = URL_SAFE_NO_PAD.decode(link) {
if let Ok(recipe_json) = String::from_utf8(decoded_bytes) {
if let Ok(recipe) = serde_json::from_str::<Recipe>(&recipe_json) {
return Ok(recipe);
}
}
}

// Handle legacy formats of 'standard base64 encoded' and standard base64 encoded that was then url encoded.
if let Ok(url_decoded) = urlencoding::decode(link) {
if let Ok(decoded_bytes) =
base64::engine::general_purpose::STANDARD.decode(url_decoded.as_bytes())
{
if let Ok(recipe_json) = String::from_utf8(decoded_bytes) {
if let Ok(recipe) = serde_json::from_str::<Recipe>(&recipe_json) {
return Ok(recipe);
}
}
}
}

Err(DecodeError::AllMethodsFailed)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::recipe::Recipe;

fn create_test_recipe() -> Recipe {
Recipe::builder()
.title("Test Recipe")
.description("A test recipe for deeplink encoding/decoding")
.instructions("Act as a helpful assistant")
.build()
.expect("Failed to build test recipe")
}

#[test]
fn test_encode_decode_round_trip() {
let original_recipe = create_test_recipe();

let encoded = encode(&original_recipe).expect("Failed to encode recipe");
assert!(!encoded.is_empty());

let decoded_recipe = decode(&encoded).expect("Failed to decode recipe");

assert_eq!(original_recipe.title, decoded_recipe.title);
assert_eq!(original_recipe.description, decoded_recipe.description);
assert_eq!(original_recipe.instructions, decoded_recipe.instructions);
assert_eq!(original_recipe.version, decoded_recipe.version);
}

#[test]
fn test_decode_legacy_standard_base64() {
let recipe = create_test_recipe();
let recipe_json = serde_json::to_string(&recipe).unwrap();
let legacy_encoded =
base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes());

let decoded_recipe = decode(&legacy_encoded).expect("Failed to decode legacy format");
assert_eq!(recipe.title, decoded_recipe.title);
assert_eq!(recipe.description, decoded_recipe.description);
assert_eq!(recipe.instructions, decoded_recipe.instructions);
}

#[test]
fn test_decode_legacy_url_encoded_base64() {
let recipe = create_test_recipe();
let recipe_json = serde_json::to_string(&recipe).unwrap();
let base64_encoded =
base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes());
let url_encoded = urlencoding::encode(&base64_encoded);

let decoded_recipe =
decode(&url_encoded).expect("Failed to decode URL-encoded legacy format");
assert_eq!(recipe.title, decoded_recipe.title);
assert_eq!(recipe.description, decoded_recipe.description);
assert_eq!(recipe.instructions, decoded_recipe.instructions);
}

#[test]
fn test_decode_invalid_input() {
let result = decode("invalid_base64!");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), DecodeError::AllMethodsFailed));
}
}
6 changes: 3 additions & 3 deletions ui/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ const RecipeEditorRoute = () => {

if (!config) {
const electronConfig = window.electron.getConfig();
config = electronConfig.recipeConfig;
config = electronConfig.recipe;
}

return <RecipeEditor config={config} />;
Expand Down Expand Up @@ -758,7 +758,7 @@ export default function App() {
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
const resumeSessionId = urlParams.get('resumeSessionId');
const recipeConfig = window.appConfig.get('recipeConfig');
const recipeConfig = window.appConfig.get('recipe');

// Check for session resume first - this takes priority over other navigation
if (resumeSessionId) {
Expand Down Expand Up @@ -979,7 +979,7 @@ export default function App() {

// Handle navigation to pair view for recipe deeplinks after router is ready
useEffect(() => {
const recipeConfig = window.appConfig.get('recipeConfig');
const recipeConfig = window.appConfig.get('recipe');
if (
recipeConfig &&
typeof recipeConfig === 'object' &&
Expand Down
Loading