Make it startable from playwright and also isolate (#5016)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga 2025-10-06 20:59:26 -04:00 committed by GitHub
parent 1690cf9091
commit d3a222ddbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 150 additions and 144 deletions

31
Cargo.lock generated
View file

@ -2705,6 +2705,7 @@ dependencies = [
"jsonschema",
"nix 0.30.1",
"once_cell",
"open",
"rand 0.8.5",
"regex",
"rmcp",
@ -3531,6 +3532,15 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-terminal"
version = "0.4.16"
@ -3542,6 +3552,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -4418,6 +4438,17 @@ version = "11.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9"
[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.73"

View file

@ -143,6 +143,15 @@ run-ui:
@echo "Running UI..."
cd ui/desktop && npm install && npm run start-gui
run-ui-playwright:
#!/usr/bin/env sh
just release-binary
echo "Running UI with Playwright debugging..."
RUN_DIR="$HOME/goose-runs/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$RUN_DIR"
echo "Using isolated directory: $RUN_DIR"
cd ui/desktop && ENABLE_PLAYWRIGHT=true GOOSE_PATH_ROOT="$RUN_DIR" npm run start-gui
run-ui-only:
@echo "Running UI..."
cd ui/desktop && npm install && npm run start-gui
@ -463,4 +472,4 @@ build-test-tools:
record-mcp-tests: build-test-tools
GOOSE_RECORD_MCP=1 cargo test --package goose --test mcp_integration_test
git add crates/goose/tests/mcp_replays/
git add crates/goose/tests/mcp_replays/

View file

@ -1,6 +1,6 @@
use anyhow::Result;
use console::style;
use etcetera::{choose_app_strategy, AppStrategy};
use goose::config::paths::Paths;
use goose::config::Config;
use serde_yaml;
@ -9,11 +9,8 @@ fn print_aligned(label: &str, value: &str, width: usize) {
}
pub fn handle_info(verbose: bool) -> Result<()> {
let data_dir = choose_app_strategy(crate::APP_STRATEGY.clone())?;
let logs_dir = data_dir
.in_state_dir("logs")
.unwrap_or_else(|| data_dir.in_data_dir("logs"));
let sessions_dir = data_dir.in_data_dir("sessions");
let logs_dir = Paths::in_state_dir("logs");
let sessions_dir = Paths::in_data_dir("sessions");
// Get paths using a stored reference to the global config
let config = Config::global();

View file

@ -1,5 +1,3 @@
use etcetera::AppStrategyArgs;
use once_cell::sync::Lazy;
pub mod cli;
pub mod commands;
pub mod logging;
@ -11,9 +9,3 @@ pub mod signal;
// Re-export commonly used types
pub use session::CliSession;
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
});

View file

@ -1,6 +1,6 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use etcetera::{choose_app_strategy, AppStrategy};
use goose::config::paths::Paths;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@ -41,11 +41,7 @@ pub struct ProjectInfoDisplay {
impl ProjectTracker {
/// Get the path to the projects.json file
fn get_projects_file() -> Result<PathBuf> {
let projects_file = choose_app_strategy(crate::APP_STRATEGY.clone())
.context("goose requires a home dir")?
.in_data_dir("projects.json");
// Ensure data directory exists
let projects_file = Paths::in_data_dir("projects.json");
if let Some(parent) = projects_file.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;

View file

@ -25,7 +25,6 @@ use goose::utils::safe_truncate;
use anyhow::{Context, Result};
use completion::GooseCompleter;
use etcetera::{choose_app_strategy, AppStrategy};
use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::types::RetryConfig;
use goose::agents::{Agent, SessionConfig};
@ -37,6 +36,7 @@ use rmcp::model::PromptMessage;
use rmcp::model::ServerNotification;
use rmcp::model::{ErrorCode, ErrorData};
use goose::config::paths::Paths;
use goose::conversation::message::{Message, MessageContent};
use rand::{distributions::Alphanumeric, Rng};
use rustyline::EditMode;
@ -413,29 +413,20 @@ impl CliSession {
let completer = GooseCompleter::new(self.completion_cache.clone());
editor.set_helper(Some(completer));
// Create and use a global history file in ~/.config/goose directory
// This allows command history to persist across different chat sessions
// instead of being tied to each individual session's messages
let strategy =
choose_app_strategy(crate::APP_STRATEGY.clone()).expect("goose requires a home dir");
let config_dir = strategy.config_dir();
let history_file = config_dir.join("history.txt");
let history_file = Paths::config_dir().join("history.txt");
// Ensure config directory exists
if let Some(parent) = history_file.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
// Load history from the global file
if history_file.exists() {
if let Err(err) = editor.load_history(&history_file) {
eprintln!("Warning: Failed to load command history: {}", err);
}
}
// Helper function to save history after commands
let save_history =
|editor: &mut rustyline::Editor<GooseCompleter, rustyline::history::DefaultHistory>| {
if let Err(err) = editor.save_history(&history_file) {

View file

@ -1,6 +1,6 @@
use goose::config::get_config_dir;
use std::{env, ffi::OsString, process::Stdio};
use goose::config::paths::Paths;
#[cfg(unix)]
#[allow(unused_imports)] // False positive: trait is used for process_group method
use std::os::unix::process::CommandExt;
@ -30,7 +30,7 @@ impl Default for ShellConfig {
// Configure environment based on shell type
let envs = if shell_name == "bash" {
let bash_env = get_config_dir().join(".bash_env").into_os_string();
let bash_env = Paths::config_dir().join(".bash_env").into_os_string();
vec![(OsString::from("BASH_ENV"), bash_env)]
} else {
vec![]

View file

@ -5,9 +5,8 @@ use axum::{
routing::{delete, get, post},
Json, Router,
};
use etcetera::{choose_app_strategy, AppStrategy};
use goose::config::paths::Paths;
use goose::config::ExtensionEntry;
use goose::config::APP_STRATEGY;
use goose::config::{Config, ConfigError};
use goose::model::ModelConfig;
use goose::providers::base::ProviderMetadata;
@ -565,11 +564,7 @@ pub async fn upsert_permissions(
)
)]
pub async fn backup_config() -> Result<Json<String>, StatusCode> {
let config_dir = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir();
let config_path = config_dir.join("config.yaml");
let config_path = Paths::config_dir().join("config.yaml");
if config_path.exists() {
let file_name = config_path
@ -630,11 +625,7 @@ pub async fn recover_config() -> Result<Json<String>, StatusCode> {
)
)]
pub async fn validate_config() -> Result<Json<String>, StatusCode> {
let config_dir = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir();
let config_path = config_dir.join("config.yaml");
let config_path = Paths::config_dir().join("config.yaml");
if !config_path.exists() {
return Ok(Json("Config file does not exist".to_string()));

View file

@ -1,7 +1,7 @@
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use crate::config::paths::Paths;
use fs2::FileExt;
use keyring::Entry;
use once_cell::sync::{Lazy, OnceCell};
use once_cell::sync::OnceCell;
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
@ -11,12 +11,6 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub static APP_STRATEGY: Lazy<AppStrategyArgs> = Lazy::new(|| AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
});
const KEYRING_SERVICE: &str = "goose";
const KEYRING_USERNAME: &str = "secrets";
@ -116,18 +110,9 @@ enum SecretStorage {
// Global instance
static GLOBAL_CONFIG: OnceCell<Config> = OnceCell::new();
pub fn get_config_dir() -> PathBuf {
choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir()
}
impl Default for Config {
fn default() -> Self {
// choose_app_strategy().config_dir()
// - macOS/Linux: ~/.config/goose/
// - Windows: ~\AppData\Roaming\Block\goose\config\
let config_dir = get_config_dir();
let config_dir = Paths::config_dir();
std::fs::create_dir_all(&config_dir).expect("Failed to create config directory");

View file

@ -1,20 +1,17 @@
use crate::config::{Config, APP_STRATEGY};
use crate::config::paths::Paths;
use crate::config::Config;
use crate::model::ModelConfig;
use crate::providers::anthropic::AnthropicProvider;
use crate::providers::base::ModelInfo;
use crate::providers::ollama::OllamaProvider;
use crate::providers::openai::OpenAiProvider;
use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
pub fn custom_providers_dir() -> std::path::PathBuf {
choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir()
.join("custom_providers")
Paths::config_dir().join("custom_providers")
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -2,12 +2,13 @@ pub mod base;
pub mod custom_providers;
mod experiments;
pub mod extensions;
pub mod paths;
pub mod permission;
pub mod signup_openrouter;
pub mod signup_tetrate;
pub use crate::agents::ExtensionConfig;
pub use base::{get_config_dir, Config, ConfigError, APP_STRATEGY};
pub use base::{Config, ConfigError};
pub use custom_providers::CustomProviderConfig;
pub use experiments::ExperimentManager;
pub use extensions::{

View file

@ -0,0 +1,60 @@
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use std::path::PathBuf;
pub struct Paths;
impl Paths {
fn get_dir(dir_type: DirType) -> PathBuf {
if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") {
let base = PathBuf::from(test_root);
match dir_type {
DirType::Config => base.join("config"),
DirType::Data => base.join("data"),
DirType::State => base.join("state"),
}
} else {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
})
.expect("goose requires a home dir");
match dir_type {
DirType::Config => strategy.config_dir(),
DirType::Data => strategy.data_dir(),
DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()),
}
}
}
pub fn config_dir() -> PathBuf {
Self::get_dir(DirType::Config)
}
pub fn data_dir() -> PathBuf {
Self::get_dir(DirType::Data)
}
pub fn state_dir() -> PathBuf {
Self::get_dir(DirType::State)
}
pub fn in_state_dir(subpath: &str) -> PathBuf {
Self::state_dir().join(subpath)
}
pub fn in_config_dir(subpath: &str) -> PathBuf {
Self::config_dir().join(subpath)
}
pub fn in_data_dir(subpath: &str) -> PathBuf {
Self::data_dir().join(subpath)
}
}
enum DirType {
Config,
Data,
State,
}

View file

@ -1,5 +1,4 @@
use super::APP_STRATEGY;
use etcetera::{choose_app_strategy, AppStrategy};
use crate::config::paths::Paths;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
@ -37,14 +36,7 @@ const SMART_APPROVE_PERMISSION: &str = "smart_approve";
/// Implements the default constructor for `PermissionManager`.
impl Default for PermissionManager {
fn default() -> Self {
// Choose the app strategy and determine the config directory
let config_dir = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir();
// Ensure the configuration directory exists
std::fs::create_dir_all(&config_dir).expect("Failed to create config directory");
let config_path = config_dir.join("permission.yaml");
let config_path = Paths::config_dir().join("permission.yaml");
// Load the existing configuration file or create an empty map if the file doesn't exist
let permission_map = if config_path.exists() {

View file

@ -1,12 +1,11 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::Agent;
use crate::config::APP_STRATEGY;
use crate::config::paths::Paths;
use crate::model::ModelConfig;
use crate::providers::create;
use crate::scheduler_factory::SchedulerFactory;
use crate::scheduler_trait::SchedulerTrait;
use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};
use lru::LruCache;
use std::num::NonZeroUsize;
use std::sync::Arc;
@ -37,10 +36,7 @@ impl AgentManager {
// Private constructor - prevents direct instantiation in production
async fn new(max_sessions: Option<usize>) -> Result<Self> {
// Construct scheduler with the standard goose-server path
let schedule_file_path = choose_app_strategy(APP_STRATEGY.clone())?
.data_dir()
.join("schedule.json");
let schedule_file_path = Paths::data_dir().join("schedule.json");
let scheduler = SchedulerFactory::create(schedule_file_path).await?;

View file

@ -1,10 +1,8 @@
use crate::config::paths::Paths;
use anyhow::{Context, Result};
use etcetera::{choose_app_strategy, AppStrategy};
use std::fs;
use std::path::PathBuf;
use crate::config::APP_STRATEGY;
/// Returns the directory where log files should be stored for a specific component.
/// Creates the directory structure if it doesn't exist.
///
@ -12,17 +10,8 @@ use crate::config::APP_STRATEGY;
///
/// * `component` - The component name (e.g., "cli", "server", "debug")
/// * `use_date_subdir` - Whether to create a date-based subdirectory
///
/// # Returns
///
/// The path to the log directory for the specified component
pub fn get_log_directory(component: &str, use_date_subdir: bool) -> Result<PathBuf> {
let home_dir =
choose_app_strategy(APP_STRATEGY.clone()).context("HOME environment variable not set")?;
let base_log_dir = home_dir
.in_state_dir("logs")
.unwrap_or_else(|| home_dir.in_data_dir("logs"));
let base_log_dir = Paths::in_state_dir("logs");
let component_dir = base_log_dir.join(component);

View file

@ -1,8 +1,8 @@
use crate::config::paths::Paths;
use crate::conversation::message::ToolRequest;
use anyhow::Result;
use blake3::Hasher;
use chrono::Utc;
use etcetera::{choose_app_strategy, AppStrategy};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
@ -35,14 +35,10 @@ impl Default for ToolPermissionStore {
impl ToolPermissionStore {
pub fn new() -> Self {
let permissions_dir = choose_app_strategy(crate::config::APP_STRATEGY.clone())
.map(|strategy| strategy.config_dir())
.unwrap_or_else(|_| PathBuf::from(".config/goose"));
Self {
permissions: HashMap::new(),
version: 1,
permissions_dir,
permissions_dir: Paths::config_dir().join("permissions"),
}
}

View file

@ -1,8 +1,8 @@
use crate::config::paths::Paths;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use axum::http;
use chrono::{DateTime, Utc};
use etcetera::{choose_app_strategy, AppStrategy};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -81,9 +81,7 @@ struct DiskCache {
impl DiskCache {
fn new() -> Self {
let cache_path = choose_app_strategy(crate::config::APP_STRATEGY.clone())
.expect("goose requires a home dir")
.in_config_dir("githubcopilot/info.json");
let cache_path = Paths::in_config_dir("githubcopilot/info.json");
Self { cache_path }
}

View file

@ -1,8 +1,8 @@
use crate::config::paths::Paths;
use anyhow::Result;
use axum::{extract::Query, response::Html, routing::get, Router};
use base64::Engine;
use chrono::{DateTime, Utc};
use etcetera::{choose_app_strategy, AppStrategy};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -38,12 +38,7 @@ struct TokenCache {
}
fn get_base_path() -> PathBuf {
// choose_app_strategy().config_dir()
// - macOS/Linux: ~/.config/goose/databricks/oauth
// - Windows: ~\AppData\Roaming\Block\goose\config\databricks\oauth\
choose_app_strategy(crate::config::APP_STRATEGY.clone())
.expect("goose requires a home dir")
.in_config_dir("databricks/oauth")
Paths::in_config_dir("databricks/oauth")
}
impl TokenCache {

View file

@ -1,10 +1,9 @@
use anyhow::{anyhow, Result};
use etcetera::{choose_app_strategy, AppStrategy};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::APP_STRATEGY;
use crate::config::paths::Paths;
use crate::recipe::read_recipe_file_content::{read_recipe_file, RecipeFile};
use crate::recipe::Recipe;
use crate::recipe::RECIPE_FILE_EXTENSIONS;
@ -14,10 +13,7 @@ const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH";
pub fn get_recipe_library_dir(is_global: bool) -> PathBuf {
if is_global {
choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir()
.join("recipes")
Paths::config_dir().join("recipes")
} else {
std::env::current_dir().unwrap().join(".goose/recipes")
}

View file

@ -7,14 +7,14 @@ use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use etcetera::{choose_app_strategy, AppStrategy};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler};
use crate::agents::AgentEvent;
use crate::agents::{Agent, SessionConfig};
use crate::config::{self, Config};
use crate::config::paths::Paths;
use crate::config::Config;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflict in test section
@ -63,18 +63,13 @@ pub fn normalize_cron_expression(src: &str) -> String {
}
pub fn get_default_scheduler_storage_path() -> Result<PathBuf, io::Error> {
let strategy = choose_app_strategy(config::APP_STRATEGY.clone())
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?;
let data_dir = strategy.data_dir();
let data_dir = Paths::data_dir();
fs::create_dir_all(&data_dir)?;
Ok(data_dir.join("schedules.json"))
}
pub fn get_default_scheduled_recipes_dir() -> Result<PathBuf, SchedulerError> {
let strategy = choose_app_strategy(config::APP_STRATEGY.clone()).map_err(|e| {
SchedulerError::StorageError(io::Error::new(io::ErrorKind::NotFound, e.to_string()))
})?;
let data_dir = strategy.data_dir();
let data_dir = Paths::data_dir();
let recipes_dir = data_dir.join("scheduled_recipes");
fs::create_dir_all(&recipes_dir).map_err(SchedulerError::StorageError)?;
tracing::debug!(

View file

@ -1,4 +1,4 @@
use crate::config::APP_STRATEGY;
use crate::config::paths::Paths;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::{Provider, MSG_COUNT_FOR_SESSION_NAME_GENERATION};
@ -6,7 +6,6 @@ use crate::recipe::Recipe;
use crate::session::extension_data::ExtensionData;
use anyhow::Result;
use chrono::{DateTime, Utc};
use etcetera::{choose_app_strategy, AppStrategy};
use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqliteConnectOptions;
@ -241,16 +240,13 @@ pub struct SessionStorage {
}
pub fn ensure_session_dir() -> Result<PathBuf> {
let data_dir = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.data_dir()
.join("sessions");
let session_dir = Paths::data_dir().join("sessions");
if !data_dir.exists() {
fs::create_dir_all(&data_dir)?;
if !session_dir.exists() {
fs::create_dir_all(&session_dir)?;
}
Ok(data_dir)
Ok(session_dir)
}
fn role_to_string(role: &Role) -> &'static str {

View file

@ -151,6 +151,11 @@ async function ensureTempDirExists(): Promise<string> {
if (started) app.quit();
if (process.env.ENABLE_PLAYWRIGHT) {
console.log('[Main] Enabling Playwright remote debugging on port 9222');
app.commandLine.appendSwitch('remote-debugging-port', '9222');
}
// In development mode, force registration as the default protocol client
// In production, register normally
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
@ -519,10 +524,9 @@ const createChat = async (
const settings = loadSettings();
updateSchedulingEngineEnvironment(settings.schedulingEngine);
// Start new Goosed process for regular windows
// Pass through scheduling engine environment variables
const envVars = {
GOOSE_SCHEDULER_TYPE: process.env.GOOSE_SCHEDULER_TYPE,
GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT,
};
const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed(
app,
@ -1679,7 +1683,6 @@ async function appMain() {
// Ensure Windows shims are available before any MCP processes are spawned
await ensureWinShims();
// Register update IPC handlers once (but don't setup auto-updater yet)
registerUpdateIpcHandlers();
// Handle microphone permission requests