Skip to content
Closed
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
104 changes: 75 additions & 29 deletions crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ use crate::builtin_extension::get_builtin_extension;
use crate::config::extensions::name_to_key;
use crate::config::search_path::SearchPaths;
use crate::config::{get_all_extensions, Config};
use crate::oauth::oauth_flow;
use crate::oauth::{oauth_flow, GooseCredentialStore};
use crate::prompt_template;
use crate::subprocess::configure_subprocess;
use rmcp::model::{
CallToolRequestParams, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource,
ResourceContents, ServerInfo, Tool,
};
use rmcp::transport::auth::AuthClient;
use rmcp::transport::auth::{AuthClient, CredentialStore};
use schemars::_private::NoSerialize;
use serde_json::Value;

Expand Down Expand Up @@ -411,6 +411,43 @@ pub(crate) fn substitute_env_vars(value: &str, env_map: &HashMap<String, String>
const GOOSE_USER_AGENT: reqwest::header::HeaderValue =
reqwest::header::HeaderValue::from_static(concat!("goose/", env!("CARGO_PKG_VERSION")));

#[allow(clippy::too_many_arguments)]
async fn connect_with_auth(
auth_manager: rmcp::transport::AuthorizationManager,
uri: &str,
timeout: Duration,
provider: SharedProvider,
client_name: String,
capabilities: GooseMcpClientCapabilities,
roots_dir: &std::path::Path,
) -> ExtensionResult<Box<dyn McpClientTrait>> {
let mut auth_headers = HeaderMap::new();
auth_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT);
let auth_http_client = reqwest::Client::builder()
.default_headers(auth_headers)
.build()
.map_err(|_| ExtensionError::ConfigError("could not construct http client".to_string()))?;
let auth_client = AuthClient::new(auth_http_client, auth_manager);
let transport = StreamableHttpClientTransport::with_client(
auth_client,
StreamableHttpClientTransportConfig {
uri: uri.into(),
..Default::default()
},
);
Ok(Box::new(
McpClient::connect(
transport,
timeout,
provider,
client_name,
capabilities,
roots_dir.to_path_buf(),
)
.await?,
))
}

#[allow(clippy::too_many_arguments)]
async fn create_streamable_http_client(
uri: &str,
Expand Down Expand Up @@ -451,6 +488,32 @@ async fn create_streamable_http_client(

let timeout_duration = Duration::from_secs(resolve_timeout(timeout));

// If we have stored OAuth credentials, try refreshing and connecting directly.
// This avoids the unnecessary 401 → browser re-auth cycle on every new session.
let credential_store = GooseCredentialStore::new(name.to_string());
if credential_store.load().await.is_ok_and(|c| c.is_some()) {
match oauth_flow(&uri.to_string(), &name.to_string()).await {
Ok(auth_manager) => {
return connect_with_auth(
auth_manager,
uri,
timeout_duration,
provider,
client_name,
capabilities,
roots_dir,
)
.await;
}
Err(e) => {
warn!(
"[OAuth:{}] Proactive refresh failed: {}, falling back to unauthenticated attempt",
name, e
);
}
}
}

let client_res = McpClient::connect(
transport,
timeout_duration,
Expand All @@ -464,33 +527,16 @@ async fn create_streamable_http_client(
if should_attempt_oauth_fallback(&client_res) {
match oauth_flow(&uri.to_string(), &name.to_string()).await {
Ok(auth_manager) => {
let mut auth_headers = HeaderMap::new();
auth_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT);
let auth_http_client = reqwest::Client::builder()
.default_headers(auth_headers)
.build()
.map_err(|_| {
ExtensionError::ConfigError("could not construct http client".to_string())
})?;
let auth_client = AuthClient::new(auth_http_client, auth_manager);
let transport = StreamableHttpClientTransport::with_client(
auth_client,
StreamableHttpClientTransportConfig {
uri: uri.into(),
..Default::default()
},
);
Ok(Box::new(
McpClient::connect(
transport,
timeout_duration,
provider,
client_name,
capabilities,
roots_dir.to_path_buf(),
)
.await?,
))
connect_with_auth(
auth_manager,
uri,
timeout_duration,
provider,
client_name,
capabilities,
roots_dir,
)
.await
}
Err(_) => Ok(Box::new(client_res?)),
}
Expand Down
18 changes: 13 additions & 5 deletions crates/goose/src/oauth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod persist;

pub use persist::GooseCredentialStore;

use axum::extract::{Query, State};
use axum::response::Html;
use axum::routing::get;
Expand All @@ -14,8 +16,6 @@ use std::sync::Arc;
use tokio::sync::{oneshot, Mutex};
use tracing::warn;

use crate::oauth::persist::GooseCredentialStore;

const CALLBACK_TEMPLATE: &str = include_str!("oauth_callback.html");

#[derive(Clone)]
Expand All @@ -38,12 +38,20 @@ pub async fn oauth_flow(
auth_manager.set_credential_store(credential_store.clone());

if auth_manager.initialize_from_store().await? {
if auth_manager.refresh_token().await.is_ok() {
return Ok(auth_manager);
match auth_manager.refresh_token().await {
Ok(_) => {
return Ok(auth_manager);
}
Err(e) => {
warn!(
"[OAuth:{}] Token refresh failed: {} - clearing stored credentials and falling back to browser auth",
name, e
);
}
}

if let Err(e) = credential_store.clear().await {
warn!("error clearing bad credentials: {}", e);
warn!("[OAuth:{}] error clearing bad credentials: {}", name, e);
}
}

Expand Down
16 changes: 16 additions & 0 deletions ui/desktop/src/components/Layout/CondensedRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defineMessages, useIntl } from '../../i18n';
import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown, SessionsList } from './navigation';
import { ChatHistorySearch } from '../conversation/ChatHistorySearch';
import type { NavigationRendererProps } from './navigation/types';

const i18n = defineMessages({
Expand Down Expand Up @@ -76,6 +77,21 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
<div className="bg-background-primary rounded-lg self-stretch w-[160px] flex-shrink-0" />
)}

{/* Search bar */}
<div
className={cn(
isVertical ? 'w-full px-2 pb-2' : 'py-2 px-4',
isCondensedIconOnly && 'hidden'
)}
>
<ChatHistorySearch
onSessionClick={onSessionClick}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
activeSessionId={activeSessionId}
/>
</div>

{/* Navigation items */}
{isVertical ? (
<div className="flex-1 min-h-0 flex flex-col gap-[2px]">
Expand Down
11 changes: 11 additions & 0 deletions ui/desktop/src/components/Layout/ExpandedRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Z_INDEX } from './constants';
import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown } from './navigation';
import { ChatHistorySearch } from '../conversation/ChatHistorySearch';
import type { NavigationRendererProps } from './navigation/types';

export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
Expand Down Expand Up @@ -141,6 +142,16 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
alignContent: 'start',
}}
>
{/* Search bar - spans full width */}
<div className="col-span-full p-2">
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Mark chat search tile as no-drag in top nav

In the non-overlay top navigation mode, this grid inherits WebkitAppRegion: 'drag' (see dragStyle in the same file), so interactive children must opt out with no-drag. The newly added search tile does not, which makes the search input and result buttons behave like window-drag regions in Electron instead of clickable controls. This effectively breaks chat-history search for users who place navigation at the top.

Useful? React with 👍 / 👎.

<ChatHistorySearch
onSessionClick={onSessionClick}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
activeSessionId={activeSessionId}
/>
</div>

{visibleItems.map((item, index) => {
const Icon = item.icon;
const active = isActive(item.path);
Expand Down
Loading
Loading