feat - updating icons and glyphs, added claude code subscription integration

This commit is contained in:
2026-10-04 18:20:58 -05:00
parent 188489c890
commit b29882d56c
49 changed files with 2374 additions and 288 deletions
+22
View File
@@ -189,6 +189,8 @@ fn preset_version(agent_id: &str, custom_command: &str) -> Option<&'static str>
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => Some(CODEX_ACP_NPM_VERSION),
"opencode" => Some(OPENCODE_NPM_VERSION),
#[cfg(feature = "local_fs")]
"claude-acp" => Some(super::setup::CLAUDE_ACP_VERSION),
_ => None,
}
}
@@ -241,6 +243,26 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
#[cfg(feature = "local_fs")]
"claude-acp" => super::setup::resolve_claude_code_launch(),
// Removed from the setup catalog, but keep saved connections and their
// conversation launch identities usable rather than silently migrating
// an existing session to Claude Code.
"gemini" | "cline" => AcpLaunchConfig::new(agent_id.trim().to_ascii_lowercase())
.args(["--acp"])
.resolve_command(),
"cursor" => AcpLaunchConfig::new("cursor-agent")
.args(["acp"])
.resolve_command(),
"github-copilot-cli" => AcpLaunchConfig::new("copilot")
.args(["--acp"])
.resolve_command(),
"goose" => AcpLaunchConfig::new("goose")
.args(["acp"])
.resolve_command(),
"auggie" => AcpLaunchConfig::new("auggie")
.args(["acp"])
.resolve_command(),
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!(
"{error} Configure a custom ACP executable if this client uses a different command."
+4
View File
@@ -8,6 +8,8 @@ mod launch;
mod permissions;
mod prompt;
mod runtime_model;
#[cfg(feature = "local_fs")]
mod setup;
mod transport;
pub(crate) use launch::{
@@ -17,6 +19,8 @@ pub(crate) use launch::{
};
pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
#[cfg(feature = "local_fs")]
pub(crate) use setup::ensure_claude_code_installed;
pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpTurnControlSlot, GalaxyMcpTarget,
+282
View File
@@ -0,0 +1,282 @@
//! Explicit Claude Code setup. Downloads happen only when the user tests a
//! connection; normal launches resolve installed components without network I/O.
use std::path::{Component, Path, PathBuf};
use std::process::Output;
use std::time::Duration;
use command::r#async::Command;
use futures::future::{select, Either};
use galaxy_acp::AcpLaunchConfig;
use tokio::sync::Mutex;
// Keep the adapter and the official native CLI installation reproducible.
// npm verifies package integrity and selects Anthropic's platform binary.
pub(super) const CLAUDE_ACP_VERSION: &str = "0.85.1";
const CLAUDE_CODE_VERSION: &str = "2.1.288";
static SETUP_LOCK: Mutex<()> = Mutex::const_new(());
fn installation_dir() -> PathBuf {
galaxy_core::paths::data_dir()
.join("acp")
.join("claude-code")
}
fn installed_command(command: &str) -> Option<PathBuf> {
AcpLaunchConfig::new(command)
.resolve_command()
.ok()
.map(|launch| launch.command)
}
fn package_binary(package_dir: &Path) -> Option<PathBuf> {
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package_dir.join("package.json")).ok()?).ok()?;
let binary = manifest.get("bin")?.get("claude")?.as_str()?;
let binary = Path::new(binary);
if binary.is_absolute()
|| binary
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return None;
}
let binary = package_dir.join(binary);
binary.is_file().then_some(binary)
}
fn claude_binary() -> Option<PathBuf> {
installed_command("claude")
.or_else(|| {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".local/bin/claude");
path.is_file().then_some(path)
})
.or_else(|| {
package_binary(&installation_dir().join("node_modules/@anthropic-ai/claude-code"))
})
}
fn adapter_script() -> PathBuf {
installation_dir().join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
}
fn managed_adapter_ready(directory: &Path) -> bool {
directory
.join(format!("ready-{CLAUDE_ACP_VERSION}"))
.is_file()
&& directory
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
.is_file()
}
pub(super) fn resolve_claude_code_launch() -> Result<AcpLaunchConfig, String> {
let claude = claude_binary().ok_or_else(|| {
"Claude Code is not installed. Test the Claude Code connection in Settings to download it."
.to_owned()
})?;
let launch = if let Some(adapter) = installed_command("claude-agent-acp") {
AcpLaunchConfig::new(adapter)
} else {
let script = adapter_script();
if !managed_adapter_ready(&installation_dir()) {
return Err("The Claude Code ACP adapter is missing. Test the connection in Settings to download it.".to_owned());
}
let managed_node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let node = if managed_node.is_file() {
managed_node
} else {
installed_command("node").ok_or_else(|| "Claude Code's ACP adapter needs Node.js. Test the connection in Settings to install it.".to_owned())?
};
AcpLaunchConfig::new(node).args([script.to_string_lossy().into_owned()])
};
let mut path = launch
.command
.parent()
.into_iter()
.map(Path::to_owned)
.collect::<Vec<_>>();
if let Ok(node) = node_runtime::node_binary_path() {
if node.is_file() {
if let Some(directory) = node.parent() {
path.insert(0, directory.to_owned());
}
}
}
if let Some(existing) = std::env::var_os("PATH") {
path.extend(std::env::split_paths(&existing));
}
let path = std::env::join_paths(path).map_err(|error| error.to_string())?;
Ok(launch
// The adapter must use this unmodified binary, rather than silently
// selecting another SDK-bundled CLI. Auth stays entirely in Claude Code.
.env("CLAUDE_CODE_EXECUTABLE", claude.to_string_lossy())
.env("PATH", path.to_string_lossy()))
}
/// Prepare missing components and authenticate with Claude Code's own browser
/// flow. Never read its token store or include auth command output in logs.
pub(crate) async fn ensure_claude_code_installed(
progress: async_channel::Sender<&'static str>,
) -> Result<(), String> {
let _lock = SETUP_LOCK.lock().await;
let needs_cli = claude_binary().is_none();
let needs_adapter = installed_command("claude-agent-acp").is_none()
&& !managed_adapter_ready(&installation_dir());
if needs_cli || needs_adapter {
let _ = progress.try_send("Downloading missing Claude Code components…");
log::info!("Claude Code setup: missing_cli={needs_cli}, missing_adapter={needs_adapter}");
// Reuse Galaxy's private Node runtime; no global install or administrator
// permission is needed, even when Node/npm are absent from the user's PATH.
let client = http_client::Client::new();
match select(
Box::pin(node_runtime::install_npm(&client)),
Box::pin(async_io::Timer::after(Duration::from_secs(300))),
)
.await
{
Either::Left((result, _)) => {
result.map_err(|error| {
format!("Could not install Claude Code's Node runtime: {error}")
})?;
}
Either::Right(_) => {
return Err(
"Downloading Claude Code's Node runtime timed out. Test again to retry."
.to_owned(),
)
}
}
let node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let npm = node_runtime::npm_binary_path().map_err(|error| error.to_string())?;
let directory = installation_dir();
async_fs::create_dir_all(&directory)
.await
.map_err(|error| error.to_string())?;
let mut command = Command::new(&node);
command
.arg(npm)
.args([
"install",
"--registry=https://registry.npmjs.org",
"--ignore-scripts=false",
"--no-audit",
"--no-fund",
"--save-exact",
])
.arg("--prefix")
.arg(&directory)
.current_dir(&directory);
if needs_cli {
command.arg(format!("@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}"));
}
if needs_adapter {
command.arg(format!(
"@agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}"
));
}
let mut paths = vec![node
.parent()
.ok_or("Node runtime has no directory")?
.to_owned()];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
command.env(
"PATH",
std::env::join_paths(paths).map_err(|error| error.to_string())?,
);
let output = run_command(&mut command, Duration::from_secs(300)).await?;
if !output.status.success() {
// Installation runs no model request and has no Claude credentials.
let error = String::from_utf8_lossy(&output.stderr);
let detail = error.chars().take(2000).collect::<String>();
return Err(format!(
"Could not download Claude Code components: {detail}"
));
}
if needs_adapter {
if !adapter_script().is_file() {
return Err("The Claude Code adapter download completed without its executable. Test again to retry.".to_owned());
}
// A cancelled/failed npm install may leave the entrypoint behind.
// Only a completed install is eligible for future launches.
async_fs::write(
directory.join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.await
.map_err(|error| error.to_string())?;
}
}
let launch = resolve_claude_code_launch()?;
let claude = launch
.env
.get("CLAUDE_CODE_EXECUTABLE")
.ok_or("Claude Code executable is missing")?;
let _ = progress.try_send("Checking Claude Code sign-in…");
let status = run_command(
claude_auth_command(claude).args(["auth", "status", "--json"]),
Duration::from_secs(15),
)
.await?;
let status_json: serde_json::Value = serde_json::from_slice(&status.stdout)
.map_err(|_| "Claude Code could not report its login status. Run `claude auth login`, then test again.".to_owned())?;
if !status.status.success()
|| status_json
.get("loggedIn")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
let _ = progress.try_send("Opening Claude Code sign-in in your browser…");
let login = run_command(
claude_auth_command(claude).args(["auth", "login", "--claudeai"]),
Duration::from_secs(300),
)
.await?;
if !login.status.success() {
return Err("Claude Code sign-in did not complete. Complete sign-in through Claude Code and test again.".to_owned());
}
}
let _ = progress.try_send("Discovering Claude Code models…");
log::info!("Claude Code setup: components ready; starting ACP discovery");
Ok(())
}
fn claude_auth_command(claude: &str) -> Command {
let mut command = Command::new(claude);
// Match the ACP launch's sanitized environment: an inherited API key or
// provider redirect must not make setup report a subscription as signed in.
for variable in [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
] {
command.env_remove(variable);
}
command
}
async fn run_command(command: &mut Command, timeout: Duration) -> Result<Output, String> {
command.kill_on_drop(true);
match select(
Box::pin(command.output()),
Box::pin(async_io::Timer::after(timeout)),
)
.await
{
Either::Left((result, _)) => result.map_err(|error| error.to_string()),
Either::Right(_) => {
Err("Claude Code setup timed out. Test the connection again to retry.".to_owned())
}
}
}
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;
+52
View File
@@ -0,0 +1,52 @@
use super::*;
#[test]
fn incomplete_adapter_installation_is_not_launchable() {
let directory = tempfile::tempdir().unwrap();
let script = directory
.path()
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, b"entrypoint").unwrap();
assert!(!managed_adapter_ready(directory.path()));
std::fs::write(
directory.path().join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.unwrap();
assert!(managed_adapter_ready(directory.path()));
std::fs::remove_file(script).unwrap();
assert!(!managed_adapter_ready(directory.path()));
}
#[test]
fn official_package_binary_is_resolved_from_its_manifest() {
let directory = tempfile::tempdir().unwrap();
std::fs::create_dir(directory.path().join("bin")).unwrap();
std::fs::write(directory.path().join("bin/claude.exe"), b"binary").unwrap();
std::fs::write(
directory.path().join("package.json"),
r#"{"bin":{"claude":"bin/claude.exe"}}"#,
)
.unwrap();
assert_eq!(
package_binary(directory.path()),
Some(directory.path().join("bin/claude.exe"))
);
}
#[test]
fn package_binary_cannot_escape_the_installation() {
let directory = tempfile::tempdir().unwrap();
let package = directory.path().join("package");
std::fs::create_dir(&package).unwrap();
std::fs::write(directory.path().join("claude"), b"outside").unwrap();
for binary in ["../claude", "/bin/sh", "missing"] {
std::fs::write(
package.join("package.json"),
serde_json::json!({"bin": {"claude": binary}}).to_string(),
)
.unwrap();
assert_eq!(package_binary(&package), None);
}
}
@@ -333,17 +333,16 @@ impl SearchCodebaseExecutor {
));
};
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
let (is_cross_repo, search_dir) = if FeatureFlag::CrossRepoContext.is_enabled() {
(
codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path)),
codebase_path.unwrap_or(current_working_directory),
)
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
(false, current_working_directory)
};
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
+9 -26
View File
@@ -79,7 +79,7 @@ use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
use crate::ai::blocklist::view_util::error_color;
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
use crate::ai::loading::shimmering_warp_loading_text;
use crate::ai::loading::{loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP};
use crate::ai::AIRequestUsageModel;
use crate::code::editor::view::CodeEditorView;
use crate::code::editor_management::CodeSource;
@@ -625,14 +625,9 @@ pub fn render_warping_indicator_base(
// will be rendered on a second line below the warping text. Captured before
// `secondary_element` is consumed so the container can reserve room for it.
let has_secondary_element = secondary_element.is_some();
// Unicode code point for the Warp glyph that is embedded in the version of Roboto we bundle
// into the app. This code point MUST be rendered using Roboto (the default ui font) or else the
// glyph may not be rendered.
const WARP_GLYPH: &str = "\u{E500}";
let appearance = Appearance::as_ref(app);
let should_indent_tip_for_warp_glyph = matches!(
let should_indent_tip_for_logo = matches!(
warping_indicator_text,
MaybeShimmeringText::Shimmering { .. }
);
@@ -640,7 +635,9 @@ pub fn render_warping_indicator_base(
let text = render_output_status_text(warping_indicator_text, appearance, app);
let text_content = {
let mut row = Flex::row().with_child(Shrinkable::new(1., text).finish());
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., text).finish());
if let Some(non_shimmering) = non_shimmering_text {
let additional = render_output_status_text(
@@ -660,25 +657,11 @@ pub fn render_warping_indicator_base(
let mut text_col = Flex::column();
if let Some(sub_element) = secondary_element {
// Our warping indicator text prepends the Warp glyph (and a space) to the label.
// If we render the tip directly underneath, it will align to the glyph instead of
// the start of the actual warping text.
let sub_element = if should_indent_tip_for_warp_glyph {
// Align the tip with the text after the Galaxy logo and its spacing.
let sub_element = if should_indent_tip_for_logo {
let font_size = appearance.monospace_font_size() - 3.;
let glyph_indent = Text::new_inline(
format!("{WARP_GLYPH} "),
appearance.ui_font_family(),
font_size,
)
.with_color(ColorU::new(0, 0, 0, 0))
.with_selectable(false)
.soft_wrap(false)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(glyph_indent)
.with_child(Shrinkable::new(1., sub_element).finish())
Container::new(Shrinkable::new(1., sub_element).finish())
.with_margin_left(loading_logo_size(font_size) + LOADING_LOGO_GAP)
.finish()
} else {
Shrinkable::new(1., sub_element).finish()
+443
View File
@@ -0,0 +1,443 @@
//! Pi-compatible Claude subscription OAuth. Secrets are never logged or serialized into settings.
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct Credentials {
pub(crate) access_token: String,
refresh_token: String,
expires_at: u64,
}
#[cfg(not(target_family = "wasm"))]
pub(crate) use desktop::*;
#[cfg(not(target_family = "wasm"))]
mod desktop {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use futures::channel::oneshot;
use futures::StreamExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant;
use rand::RngCore;
use settings::Setting;
use sha2::{Digest, Sha256};
use url::Url;
use super::Credentials;
use crate::ai::claude_subscription::{is_stored_credential, OAUTH_REFERENCE_PREFIX};
use crate::settings::AISettings;
// Pi's browser flow, checked 2026-10-04:
// https://github.com/earendil-works/pi/blob/main/packages/ai/src/auth/oauth/anthropic.ts
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const REDIRECT_URI: &str = "http://localhost:53692/callback";
const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
type Reply = oneshot::Sender<Result<String, String>>;
enum Command {
Resolve(String, Reply),
Invalidate(String),
DiscardDraft(String),
}
static SENDER: OnceLock<Mutex<Option<async_channel::Sender<Command>>>> = OnceLock::new();
fn send(command: Command) -> Result<(), String> {
SENDER
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap()
.as_ref()
.ok_or("Claude authorization service is unavailable.")?
.try_send(command)
.map_err(|_| "Claude authorization service is busy or unavailable.".to_string())
}
pub(crate) fn invalidate(reference: &str) {
let _ = send(Command::Invalidate(reference.to_string()));
}
pub(crate) fn discard_draft(reference: &str) {
if reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = send(Command::DiscardDraft(reference.to_string()));
}
}
pub(crate) async fn load_credential(reference: String) -> Result<String, String> {
if !is_stored_credential(&reference) {
return Ok(reference);
}
let (tx, rx) = oneshot::channel();
send(Command::Resolve(reference, tx))?;
let result = futures::future::select(
Box::pin(rx),
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
)
.await;
match result {
futures::future::Either::Left((result, _)) => {
result.map_err(|_| "Claude authorization service stopped.".to_string())?
}
futures::future::Either::Right(_) => {
Err("Claude token refresh timed out. Try again or sign in again.".to_string())
}
}
}
struct Refresh {
id: uuid::Uuid,
replies: Vec<Reply>,
}
/// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes.
pub(crate) struct ClaudeOAuthModel {
refreshing: HashMap<String, Refresh>,
}
impl Entity for ClaudeOAuthModel {
type Event = ();
}
impl SingletonEntity for ClaudeOAuthModel {}
impl ClaudeOAuthModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let (tx, rx) = async_channel::bounded(64);
*SENDER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
ctx.spawn_stream_local(
rx,
|me, command, ctx| match command {
Command::Resolve(reference, reply) => me.resolve(reference, reply, ctx),
Command::Invalidate(reference) => me.invalidate(&reference),
Command::DiscardDraft(reference) => {
let saved = AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(&reference));
if !saved {
me.invalidate(&reference);
let _ = ctx.secure_storage().remove_value(&reference);
}
}
},
|_, _| {},
);
Self {
refreshing: HashMap::new(),
}
}
fn invalidate(&mut self, reference: &str) {
if let Some(refresh) = self.refreshing.remove(reference) {
for reply in refresh.replies {
let _ = reply.send(Err("Claude subscription was signed out.".to_string()));
}
}
}
fn resolve(&mut self, reference: String, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if let Some(refresh) = self.refreshing.get_mut(&reference) {
refresh.replies.push(reply);
return;
}
let value = match ctx.secure_storage().read_value(&reference) {
Ok(value) => value,
Err(_) => {
let _ = reply.send(Err(
"Claude subscription is signed out. Sign in again.".to_string()
));
return;
}
};
if !reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = reply.send(Ok(value));
return;
}
let credentials: Credentials = match serde_json::from_str(&value) {
Ok(credentials) => credentials,
Err(_) => {
let _ = reply.send(Err(
"Claude credentials are invalid. Sign in again.".to_string()
));
return;
}
};
if credentials.expires_at > now() {
let _ = reply.send(Ok(credentials.access_token));
return;
}
let id = uuid::Uuid::new_v4();
self.refreshing.insert(
reference.clone(),
Refresh {
id,
replies: vec![reply],
},
);
ctx.spawn(
async move {
token_request(serde_json::json!({
"grant_type": "refresh_token", "client_id": CLIENT_ID,
"refresh_token": credentials.refresh_token,
}))
.await
},
move |me, result, ctx| {
if me
.refreshing
.get(&reference)
.is_none_or(|refresh| refresh.id != id)
{
return;
}
let refresh = me.refreshing.remove(&reference).unwrap();
let result = result.and_then(|credentials| {
// Signing out deletes the store entry before this callback can commit.
if ctx.secure_storage().read_value(&reference).is_err() {
return Err("Claude subscription was signed out.".to_string());
}
save_credentials(&reference, &credentials, ctx)?;
Ok(credentials.access_token)
});
for reply in refresh.replies {
let _ = reply.send(result.clone());
}
},
);
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn save_credentials(
reference: &str,
credentials: &Credentials,
ctx: &galaxyui::AppContext,
) -> Result<(), String> {
let json = serde_json::to_string(credentials)
.map_err(|_| "Could not encode Claude credentials.".to_string())?;
ctx.secure_storage()
.write_value_with_owner_only_fallback(reference, &json)
.map_err(|_| {
"Could not securely save Claude credentials. Sign-in was not saved.".to_string()
})
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
async fn token_request(body: serde_json::Value) -> Result<Credentials, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "Could not create Claude authorization client.".to_string())?;
let response = client
.post(TOKEN_URL)
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|_| {
"Claude authorization request failed. Try signing in again.".to_string()
})?;
if !response.status().is_success() {
// Never expose response bodies: providers may echo authorization codes or tokens.
return Err(format!(
"Claude authorization failed (HTTP {}). Sign in again.",
response.status().as_u16()
));
}
if response
.content_length()
.is_some_and(|length| length > 64 * 1024)
{
return Err("Claude authorization returned an oversized response.".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "Claude authorization response failed.".to_string())?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err("Claude authorization returned an oversized response.".to_string());
}
bytes.extend_from_slice(&chunk);
}
let tokens: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|_| "Claude authorization returned invalid credentials.".to_string())?;
credentials_from_response(tokens)
}
fn credentials_from_response(tokens: TokenResponse) -> Result<Credentials, String> {
if !tokens.access_token.contains("sk-ant-oat")
|| tokens.refresh_token.is_empty()
|| tokens.expires_in == 0
{
return Err("Claude authorization returned incomplete credentials.".to_string());
}
Ok(Credentials {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: now()
.saturating_add(tokens.expires_in)
.saturating_sub(5 * 60),
})
}
pub(crate) struct BrowserLogin {
pub(crate) url: Url,
verifier: String,
callback: oneshot::Receiver<Result<String, String>>,
cancelled: Arc<AtomicBool>,
}
impl Drop for BrowserLogin {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
}
}
impl BrowserLogin {
pub(crate) fn start() -> Result<Self, String> {
let listener = TcpListener::bind(("127.0.0.1", 53692))
.map_err(|_| "Claude sign-in needs localhost port 53692. Close another sign-in attempt and try again.".to_string())?;
listener
.set_nonblocking(true)
.map_err(|_| "Could not configure Claude sign-in callback.".to_string())?;
let mut bytes = [0; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let url = authorize_url(&verifier, &challenge)?;
let cancelled = Arc::new(AtomicBool::new(false));
let stop = cancelled.clone();
let state = verifier.clone();
let (tx, callback) = oneshot::channel();
std::thread::Builder::new()
.name("claude-oauth-callback".to_string())
.spawn(move || {
let deadline = Instant::now() + SIGN_IN_TIMEOUT;
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
if let Some(code) = read_callback(stream, &state) {
let _ = tx.send(Ok(code));
return;
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(50))
}
Err(_) => {
let _ = tx.send(Err("Claude sign-in callback failed.".to_string()));
return;
}
}
}
let _ = tx.send(Err(
"Claude browser sign-in expired or was cancelled.".to_string()
));
})
.map_err(|_| "Could not start Claude sign-in callback.".to_string())?;
Ok(Self {
url,
verifier,
callback,
cancelled,
})
}
pub(crate) async fn finish(mut self) -> Result<Credentials, String> {
let code = (&mut self.callback)
.await
.map_err(|_| "Claude sign-in callback stopped.".to_string())??;
token_request(serde_json::json!({
"grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code,
"state": self.verifier, "redirect_uri": REDIRECT_URI, "code_verifier": self.verifier,
})).await
}
}
pub(super) fn authorize_url(verifier: &str, challenge: &str) -> Result<Url, String> {
let mut url = Url::parse(AUTHORIZE_URL)
.map_err(|_| "Invalid Claude authorization URL.".to_string())?;
url.query_pairs_mut()
.append_pair("code", "true")
.append_pair("client_id", CLIENT_ID)
.append_pair("response_type", "code")
.append_pair("redirect_uri", REDIRECT_URI)
.append_pair("scope", SCOPES)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", verifier);
Ok(url)
}
pub(super) fn callback_code(request: &str, state: &str) -> Option<String> {
let mut parts = request.split_whitespace();
if parts.next()? != "GET" {
return None;
}
let path = parts.next()?;
if !path.starts_with('/') || path.starts_with("//") {
return None;
}
let url = Url::parse(&format!("http://localhost:53692{path}")).ok()?;
if url.path() != "/callback" {
return None;
}
let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
if params.get("state")?.as_str() != state {
return None;
}
params.get("code").filter(|code| !code.is_empty()).cloned()
}
fn read_callback(mut stream: TcpStream, state: &str) -> Option<String> {
stream.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.ok()?;
let mut request = String::new();
// Bound the request line independently of whether a newline is supplied.
std::io::Read::take(BufReader::new(&mut stream), 8192)
.read_line(&mut request)
.ok()?;
let code = callback_code(&request, state);
let (status, message) = if code.is_some() {
(
"200 OK",
"Signed in to Claude. You can close this tab and return to Galaxy.",
)
} else {
("400 Bad Request", "This callback did not match the active Galaxy sign-in. Return to Galaxy and try again.")
};
let body = format!("<!doctype html><title>Galaxy — Claude sign-in</title><p>{message}</p>");
let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.write_all(response.as_bytes());
code
}
}
#[cfg(all(test, not(target_family = "wasm")))]
#[path = "claude_oauth_tests.rs"]
mod tests;
+47
View File
@@ -0,0 +1,47 @@
use super::desktop::{authorize_url, callback_code};
#[test]
fn browser_login_matches_pi_pkce_parameters() {
let url = authorize_url("test-verifier", "test-challenge").unwrap();
assert_eq!(url.origin().ascii_serialization(), "https://claude.ai");
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(params["redirect_uri"], "http://localhost:53692/callback");
assert_eq!(params["client_id"], "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
assert_eq!(params["state"], "test-verifier");
assert_eq!(params["code_challenge"], "test-challenge");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["response_type"], "code");
assert_eq!(params["code"], "true");
assert!(params["scope"].contains("user:inference"));
}
#[test]
fn callback_requires_matching_state_and_expected_path() {
assert_eq!(
callback_code("GET /callback?code=abc&state=expected HTTP/1.1", "expected"),
Some("abc".to_string())
);
for request in [
"GET /callback?code=abc&state=wrong HTTP/1.1",
"GET /callback?code=abc HTTP/1.1",
"GET /callback?state=expected HTTP/1.1",
"GET /favicon.ico?code=abc&state=expected HTTP/1.1",
"POST /callback?code=abc&state=expected HTTP/1.1",
"GET //evil.example/callback?code=abc&state=expected HTTP/1.1",
] {
assert_eq!(callback_code(request, "expected"), None);
}
}
#[test]
fn persisted_credentials_do_not_count_as_api_keys() {
assert!(crate::ai::claude_subscription::is_stored_credential(
"galaxy-claude-oauth:test"
));
assert!(crate::ai::claude_subscription::is_subscription_credential(
"galaxy-claude-oauth:test"
));
assert!(!crate::ai::claude_subscription::is_stored_credential(
"sk-ant-api-test"
));
}
+52
View File
@@ -0,0 +1,52 @@
//! User-supplied subscription tokens stay in Galaxy's secure credential store.
use galaxyui::AppContext;
use galaxyui_extras::secure_storage::AppContextExt as _;
use crate::settings::{OpenAIProviderConfig, OpenAIProviderKind};
const TOKEN_REFERENCE_PREFIX: &str = "galaxy-claude-token:";
pub(crate) const OAUTH_REFERENCE_PREFIX: &str = "galaxy-claude-oauth:";
pub(crate) const SUBSCRIPTION_WARNING: &str = "Unsupported Claude subscription integration. Anthropic does not permit third-party applications to use Claude subscription tokens. This may stop working without notice or lead to account restrictions. Galaxy retains its own tools, permissions, discovery, and orchestration.";
pub(crate) fn is_subscription_credential(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
|| credential.starts_with(TOKEN_REFERENCE_PREFIX)
|| is_stored_credential(credential)
}
pub(crate) fn is_stored_credential(credential: &str) -> bool {
credential.starts_with(TOKEN_REFERENCE_PREFIX) || credential.starts_with(OAUTH_REFERENCE_PREFIX)
}
pub(crate) fn store_credential(
provider: &mut OpenAIProviderConfig,
ctx: &AppContext,
) -> Result<Option<String>, String> {
if provider.kind != OpenAIProviderKind::Anthropic {
return Ok(None);
}
let Some(token) = provider
.api_key
.as_deref()
.filter(|token| token.trim().contains("sk-ant-oat"))
else {
return Ok(None);
};
let reference = format!("{TOKEN_REFERENCE_PREFIX}{}", uuid::Uuid::new_v4());
ctx.secure_storage()
.write_value_with_owner_only_fallback(&reference, token.trim())
.map_err(|_| {
"Could not securely save the Claude subscription token. The connection was not saved."
.to_string()
})?;
provider.api_key = Some(reference.clone());
Ok(Some(reference))
}
pub(crate) fn remove_credential(reference: &str, ctx: &AppContext) {
if is_stored_credential(reference) {
#[cfg(not(target_family = "wasm"))]
super::claude_oauth::invalidate(reference);
let _ = ctx.secure_storage().remove_value(reference);
}
}
+3 -2
View File
@@ -1828,9 +1828,10 @@ impl LLMPreferences {
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter an Anthropic API key before testing the connection.".to_string()
"Enter an Anthropic API key or Claude subscription token before testing the connection.".to_string()
})?;
Some(discover_anthropic_models(api_key).await?)
let api_key = crate::ai::claude_oauth::load_credential(api_key.to_string()).await?;
Some(discover_anthropic_models(&api_key).await?)
}
OpenAIProviderKind::Gemini => {
let api_key = provider
+3 -1
View File
@@ -1,4 +1,6 @@
//! Loading animation components for AI features.
mod shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::{
loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP,
};
@@ -1,4 +1,4 @@
//! Shimmering Warp loading text - renders Warp logo with shimmering text for loading states.
//! Renders the Galaxy logo with shimmering text for loading states.
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{
@@ -7,10 +7,13 @@ use galaxyui::elements::shimmering_text::{
use galaxyui::elements::Element;
use galaxyui::{AppContext, SingletonEntity};
/// Warp icon glyph character
const WARP_GLYPH: &str = "\u{E500}";
pub const LOADING_LOGO_GAP: f32 = 8.;
/// Creates a shimmering text element with the Warp glyph.
pub fn loading_logo_size(font_size: f32) -> f32 {
(font_size * 1.5).max(24.)
}
/// Creates a shimmering text element beside the Galaxy logo.
pub fn shimmering_warp_loading_text(
text: impl Into<String>,
font_size: f32,
@@ -27,9 +30,8 @@ pub fn shimmering_warp_loading_text(
// Hardcoded shimmer config for consistent animation
let config = ShimmerConfig::default();
// Create a single shimmering element with glyph and text
ShimmeringTextElement::new(
format!("{} {}", WARP_GLYPH, text.into()),
text.into(),
appearance.ui_font_family(),
font_size,
base_color,
@@ -37,5 +39,10 @@ pub fn shimmering_warp_loading_text(
config,
shimmer_handle,
)
.with_leading_icon(
"bundled/svg/galaxy-mark.svg",
loading_logo_size(font_size),
LOADING_LOGO_GAP,
)
.finish()
}
+4
View File
@@ -24,6 +24,8 @@ pub(crate) mod block_context;
pub(crate) mod blocklist;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod chatgpt_auth;
pub(crate) mod claude_oauth;
pub(crate) mod claude_subscription;
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) mod codebase_auto_indexing;
pub mod control_code_parser;
@@ -87,6 +89,8 @@ pub fn init(app: &mut AppContext) {
app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
blocklist::keyboard_navigable_buttons::init(app);
blocklist::block::number_shortcut_buttons::init(app);
blocklist::toggleable_items::init(app);
+11 -3
View File
@@ -318,11 +318,19 @@ pub(crate) async fn provider_runtime_for_request(
},
)),
OpenAIProviderKind::Anthropic => {
Arc::new(AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
let credential = config.api_key.unwrap_or_default();
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
let mut runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: credential.clone(),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
}))
});
if stored {
runtime = runtime.with_credential_resolver(Arc::new(move || {
Box::pin(crate::ai::claude_oauth::load_credential(credential.clone()))
}));
}
Arc::new(runtime)
}
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),