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: 2 additions & 1 deletion crates/goose-acp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,9 @@ async fn add_builtins(agent: &Agent, builtins: Vec<String>) {
let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) {
ExtensionConfig::Platform {
name: builtin.clone(),
bundled: None,
description: builtin.clone(),
display_name: None,
bundled: None,
available_tools: Vec::new(),
}
} else {
Expand Down
3 changes: 2 additions & 1 deletion crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,9 @@ impl CliSession {
if PLATFORM_EXTENSIONS.contains_key(extension_name) {
ExtensionConfig::Platform {
name: extension_name.to_string(),
bundled: None,
description: extension_name.to_string(),
display_name: None,
bundled: None,
available_tools: Vec::new(),
}
} else {
Expand Down
2 changes: 1 addition & 1 deletion crates/goose/src/agents/code_execution_extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ impl CodeExecutionClient {
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
title: Some("Code Execution".to_string()),
title: Some("Code Mode".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
Expand Down
11 changes: 10 additions & 1 deletion crates/goose/src/agents/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
todo_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: todo_extension::EXTENSION_NAME,
display_name: "Todo",
description:
"Enable a todo list for goose so it can keep track of what it is doing",
default_enabled: true,
Expand All @@ -59,6 +60,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
apps_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: apps_extension::EXTENSION_NAME,
display_name: "Apps",
description:
"Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.",
default_enabled: true,
Expand All @@ -70,6 +72,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
chatrecall_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: chatrecall_extension::EXTENSION_NAME,
display_name: "Chat Recall",
description:
"Search past conversations and load session summaries for contextual memory",
default_enabled: false,
Expand All @@ -83,6 +86,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
"extensionmanager",
PlatformExtensionDef {
name: extension_manager_extension::EXTENSION_NAME,
display_name: "Extension Manager",
description:
"Enable extension management tools for discovering, enabling, and disabling extensions",
default_enabled: true,
Expand All @@ -94,6 +98,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
skills_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: skills_extension::EXTENSION_NAME,
display_name: "Skills",
description: "Load and use skills from relevant directories",
default_enabled: true,
client_factory: |ctx| Box::new(skills_extension::SkillsClient::new(ctx).unwrap()),
Expand All @@ -104,7 +109,9 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
code_execution_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: code_execution_extension::EXTENSION_NAME,
description: "Execute JavaScript code in a sandboxed environment",
display_name: "Code Mode",
description:
"Goose will make extension calls through code execution, saving tokens",
default_enabled: false,
client_factory: |ctx| {
Box::new(code_execution_extension::CodeExecutionClient::new(ctx).unwrap())
Expand Down Expand Up @@ -158,6 +165,7 @@ impl PlatformExtensionContext {
#[derive(Debug, Clone)]
pub struct PlatformExtensionDef {
pub name: &'static str,
pub display_name: &'static str,
pub description: &'static str,
pub default_enabled: bool,
Comment on lines 166 to 170
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

The PR description mentions adding per-extension version and version-driven migrations, but the platform extension definition/config only adds display_name (no version field), so migrations are currently based on field diffs; either implement the version field end-to-end (schema + config + migration logic) or update the PR description to match the actual behavior.

Copilot uses AI. Check for mistakes.
pub client_factory: fn(PlatformExtensionContext) -> Box<dyn McpClientTrait>,
Expand Down Expand Up @@ -335,6 +343,7 @@ pub enum ExtensionConfig {
#[serde(deserialize_with = "deserialize_null_with_default")]
#[schema(required)]
description: String,
display_name: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
Expand Down
59 changes: 34 additions & 25 deletions crates/goose/src/config/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,25 +273,34 @@ impl Config {
}

fn load(&self) -> Result<Mapping, ConfigError> {
if self.config_path.exists() {
self.load_values_with_recovery()
let mut values = if self.config_path.exists() {
self.load_values_with_recovery()?
} else {
// Config file doesn't exist, try to recover from backup first
tracing::info!("Config file doesn't exist, attempting recovery from backup");

if let Ok(backup_values) = self.try_restore_from_backup() {
tracing::info!("Successfully restored config from backup");
return Ok(backup_values);
}
backup_values
} else {
// No backup available, create a default config
tracing::info!("No backup found, creating default configuration");

// No backup available, create a default config
tracing::info!("No backup found, creating default configuration");
// Try to load from init-config.yaml if it exists, otherwise use empty config
let default_config = self.load_init_config_if_exists().unwrap_or_default();

// Try to load from init-config.yaml if it exists, otherwise use empty config
let default_config = self.load_init_config_if_exists().unwrap_or_default();
self.create_and_save_default_config(default_config)?
}
};

self.create_and_save_default_config(default_config)
// Run migrations on the loaded config
if crate::config::migrations::run_migrations(&mut values) {
if let Err(e) = self.save_values(values.clone()) {
tracing::warn!("Failed to save migrated config: {}", e);
Comment on lines +298 to +299
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

Config::load() may write the migrated config back to disk without taking self.guard, which can race with concurrent set_param/delete calls and cause lost updates; take the same mutex around the migration save (or route through a guarded write path).

Suggested change
if let Err(e) = self.save_values(values.clone()) {
tracing::warn!("Failed to save migrated config: {}", e);
// Persist migrated config under the same guard used by other writers
match self.guard.lock() {
Ok(_guard) => {
if let Err(e) = self.save_values(values.clone()) {
tracing::warn!("Failed to save migrated config: {}", e);
}
}
Err(e) => {
tracing::warn!("Failed to acquire config lock for migrated save: {}", e);
}

Copilot uses AI. Check for mistakes.
}
}

Ok(values)
}

pub fn all_values(&self) -> Result<HashMap<String, Value>, ConfigError> {
Expand Down Expand Up @@ -1203,13 +1212,7 @@ mod tests {
// Print the final values for debugging
println!("Final values: {:?}", final_values);

assert_eq!(
final_values.len(),
3,
"Expected 3 values, got {}",
final_values.len()
);

// Check that our 3 keys are present (migrations may add additional keys like "extensions")
for i in 0..3 {
let key = format!("key{}", i);
let value = format!("value{}", i);
Expand Down Expand Up @@ -1285,19 +1288,22 @@ mod tests {
// Try to load values - should create a fresh default config
let recovered_values = config.all_values()?;

// Should return empty config
assert_eq!(recovered_values.len(), 0);
// Note: migrations may add keys like "extensions", so we just verify
// that no user-defined keys exist (the config was reset)
assert!(
!recovered_values.contains_key("key1"),
"Should not have user keys after recovery"
);

// Verify that a clean config file was written to disk
let file_content = std::fs::read_to_string(config_file.path())?;

// Should be valid YAML (empty object)
// Should be valid YAML
let parsed: serde_yaml::Value = serde_yaml::from_str(&file_content)?;
assert!(parsed.is_mapping());

// Should be able to load it again without issues
let reloaded_values = config.all_values()?;
assert_eq!(reloaded_values.len(), 0);
let _reloaded_values = config.all_values()?;

Ok(())
}
Expand All @@ -1316,8 +1322,12 @@ mod tests {
// Try to load values - should create a fresh default config file
let values = config.all_values()?;

// Should return empty config
assert_eq!(values.len(), 0);
// Note: migrations may add keys like "extensions", so we just verify
// that no user-defined keys exist (the config was freshly created)
assert!(
!values.contains_key("key1"),
"Should not have user keys in fresh config"
);

// Verify that the config file was created
assert!(config_path.exists());
Expand All @@ -1328,8 +1338,7 @@ mod tests {
assert!(parsed.is_mapping());

// Should be able to load it again without issues
let reloaded_values = config.all_values()?;
assert_eq!(reloaded_values.len(), 0);
let _reloaded_values = config.all_values()?;

Ok(())
}
Expand Down
19 changes: 0 additions & 19 deletions crates/goose/src/config/extensions.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use super::base::Config;
use crate::agents::extension::PLATFORM_EXTENSIONS;
use crate::agents::ExtensionConfig;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -54,24 +53,6 @@ fn get_extensions_map() -> IndexMap<String, ExtensionEntry> {
}
}

// Always inject platform extensions (code_execution, todo, skills, etc.)
// These are internal agent extensions that should always be available
for (name, def) in PLATFORM_EXTENSIONS.iter() {
if !extensions_map.contains_key(*name) {
extensions_map.insert(
name.to_string(),
ExtensionEntry {
config: ExtensionConfig::Platform {
name: def.name.to_string(),
description: def.description.to_string(),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled: def.default_enabled,
},
);
}
}
extensions_map
}

Expand Down
141 changes: 141 additions & 0 deletions crates/goose/src/config/migrations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
use crate::agents::extension::PLATFORM_EXTENSIONS;
use crate::agents::ExtensionConfig;
use crate::config::extensions::ExtensionEntry;
use serde_yaml::Mapping;

const EXTENSIONS_CONFIG_KEY: &str = "extensions";

pub fn run_migrations(config: &mut Mapping) -> bool {
let mut changed = false;
changed |= migrate_platform_extensions(config);
changed
}
Comment on lines +8 to +12
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

The PR description and example config show a per-extension version field used to drive migrations, but this PR only adds display_name (no version field in ExtensionConfig, OpenAPI, or the migration logic); either implement the versioned migration mechanism or update the PR description/screenshots to match the actual behavior.

Copilot uses AI. Check for mistakes.

fn migrate_platform_extensions(config: &mut Mapping) -> bool {
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());

let extensions_value = config
.get(&extensions_key)
.cloned()
.unwrap_or(serde_yaml::Value::Mapping(Mapping::new()));

let mut extensions_map: Mapping = match extensions_value {
serde_yaml::Value::Mapping(m) => m,
_ => Mapping::new(),
};

let mut needs_save = false;

for (name, def) in PLATFORM_EXTENSIONS.iter() {
let ext_key = serde_yaml::Value::String(name.to_string());
let existing = extensions_map.get(&ext_key);

let needs_migration = match existing {
None => true,
Some(value) => match serde_yaml::from_value::<ExtensionEntry>(value.clone()) {
Ok(entry) => {
if let ExtensionConfig::Platform {
description,
display_name,
..
} = &entry.config
{
description != def.description
|| display_name.as_deref() != Some(def.display_name)
} else {
true
}
}
Err(_) => true,
},
};

if needs_migration {
let enabled = existing
.and_then(|v| serde_yaml::from_value::<ExtensionEntry>(v.clone()).ok())
.map(|e| e.enabled)
.unwrap_or(def.default_enabled);

let new_entry = ExtensionEntry {
config: ExtensionConfig::Platform {
name: def.name.to_string(),
description: def.description.to_string(),
display_name: Some(def.display_name.to_string()),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled,
};

if let Ok(value) = serde_yaml::to_value(&new_entry) {
extensions_map.insert(ext_key, value);
needs_save = true;
}
}
}

if needs_save {
config.insert(extensions_key, serde_yaml::Value::Mapping(extensions_map));
}

needs_save
}

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

#[test]
fn test_migrate_platform_extensions_empty_config() {
let mut config = Mapping::new();
let changed = run_migrations(&mut config);

assert!(changed);
let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
assert!(config.contains_key(&extensions_key));
}

#[test]
fn test_migrate_platform_extensions_preserves_enabled_state() {
let mut config = Mapping::new();
let mut extensions = Mapping::new();
let todo_entry = ExtensionEntry {
config: ExtensionConfig::Platform {
name: "todo".to_string(),
description: "old description".to_string(),
display_name: Some("Old Name".to_string()),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled: false,
};
extensions.insert(
serde_yaml::Value::String("todo".to_string()),
serde_yaml::to_value(&todo_entry).unwrap(),
);
config.insert(
serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()),
serde_yaml::Value::Mapping(extensions),
);

let changed = run_migrations(&mut config);
assert!(changed);

let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string());
let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap();
let todo_key = serde_yaml::Value::String("todo".to_string());
let todo_value = extensions.get(&todo_key).unwrap();
let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap();

assert!(!todo_entry.enabled);
}

#[test]
fn test_migrate_platform_extensions_idempotent() {
let mut config = Mapping::new();
run_migrations(&mut config);

let changed = run_migrations(&mut config);
assert!(!changed);
}
}
1 change: 1 addition & 0 deletions crates/goose/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod declarative_providers;
mod experiments;
pub mod extensions;
pub mod goose_mode;
mod migrations;
pub mod paths;
pub mod permission;
pub mod search_path;
Expand Down
Loading
Loading