feat: provider config management (#193)

* feat: support scoped removal of provider config (auth, user, project, custom)

* feat: implement UI session token management with cookies for window visibility control
This commit is contained in:
Bohdan Triapitsyn
2026-01-22 13:45:30 +02:00
committed by GitHub
parent d4f1d8abbf
commit 05caf4cc58
10 changed files with 778 additions and 41 deletions
+136 -10
View File
@@ -2234,6 +2234,55 @@ async fn handle_config_routes(
));
}
// Handle provider source lookup: GET /api/provider/:providerId/source
if let Some(rest) = path.strip_prefix("/api/provider/") {
if let Some(provider_id) = rest.strip_suffix("/source") {
if method == Method::GET {
let trimmed = provider_id.trim();
if trimmed.is_empty() {
return Ok(config_error_response(
StatusCode::BAD_REQUEST,
"Provider ID is required",
));
}
let requested_directory = extract_directory_from_request(&req);
let working_directory = if let Some(ref value) = requested_directory {
match resolve_project_directory(&state, Some(value.to_string())).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else {
resolve_project_directory(&state, None).await.ok()
};
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()).await {
Ok(mut sources) => {
let auth = opencode_auth::get_provider_auth(trimmed).await;
sources.auth.exists = auth.ok().flatten().is_some();
return Ok(json_response(
StatusCode::OK,
serde_json::json!({
"providerId": trimmed,
"sources": sources
}),
));
}
Err(err) => {
error!(
"[desktop:config] Failed to get provider sources {}: {}",
trimmed, err
);
return Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
}
}
}
}
// Handle provider auth removal: DELETE /api/provider/:providerId/auth
if let Some(rest) = path.strip_prefix("/api/provider/") {
if let Some(provider_id) = rest.strip_suffix("/auth") {
@@ -2246,28 +2295,100 @@ async fn handle_config_routes(
));
}
match opencode_auth::remove_provider_auth(trimmed).await {
Ok(removed) => {
if let Err(resp) = refresh_opencode_after_config_change(
&state,
&format!("provider {} disconnected", trimmed),
let scope = req
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find(|pair| pair.starts_with("scope="))
.and_then(|pair| pair.split('=').nth(1))
})
.unwrap_or("auth");
let requested_directory = extract_directory_from_request(&req);
let working_directory = if scope == "project" {
match resolve_project_directory(&state, requested_directory.clone()).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else if let Some(ref value) = requested_directory {
match resolve_project_directory(&state, Some(value.to_string())).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else {
resolve_project_directory(&state, None).await.ok()
};
let removal_result = if scope == "auth" {
opencode_auth::remove_provider_auth(trimmed).await
} else if scope == "user" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::User).await
} else if scope == "project" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Project).await
} else if scope == "custom" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Custom).await
} else if scope == "all" {
let auth_removed = opencode_auth::remove_provider_auth(trimmed).await.unwrap_or(false);
let user_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::User,
)
.await
.unwrap_or(false);
let project_removed = if let Some(ref directory) = working_directory {
opencode_config::remove_provider_config(
trimmed,
Some(directory),
opencode_config::ProviderScope::Project,
)
.await
{
return Ok(resp);
.unwrap_or(false)
} else {
false
};
let custom_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Custom,
)
.await
.unwrap_or(false);
Ok(auth_removed || user_removed || project_removed || custom_removed)
} else {
return Ok(config_error_response(
StatusCode::BAD_REQUEST,
"Invalid scope",
));
};
match removal_result {
Ok(removed) => {
if removed {
if let Err(resp) = refresh_opencode_after_config_change(
&state,
&format!("provider {} disconnected", trimmed),
)
.await
{
return Ok(resp);
}
}
return Ok(json_response(
StatusCode::OK,
ConfigActionResponse {
success: true,
requires_reload: true,
requires_reload: removed,
message: if removed {
"Provider disconnected successfully".to_string()
} else {
"Provider was not connected".to_string()
},
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
reload_delay_ms: if removed { CLIENT_RELOAD_DELAY_MS } else { 0 },
},
));
}
@@ -2406,11 +2527,16 @@ async fn proxy_to_opencode(
&& origin_path.ends_with("/auth")
&& origin_path != "/api/provider/auth"; // Exclude GET /api/provider/auth
let is_provider_source_get = method == Method::GET
&& origin_path.starts_with("/api/provider/")
&& origin_path.ends_with("/source");
let is_desktop_config_route = origin_path.starts_with("/api/config/agents/")
|| origin_path.starts_with("/api/config/commands/")
|| origin_path.starts_with("/api/config/skills")
|| origin_path == "/api/config/reload"
|| is_provider_auth_delete;
|| is_provider_auth_delete
|| is_provider_source_get;
if is_desktop_config_route {
return handle_config_routes(state, &origin_path, method, req).await;
@@ -68,6 +68,19 @@ pub async fn write_auth(auth: &Value) -> Result<()> {
Ok(())
}
/// Get provider auth entry from auth.json
pub async fn get_provider_auth(provider_id: &str) -> Result<Option<Value>> {
if provider_id.is_empty() {
return Err(anyhow!("Provider ID is required"));
}
let auth = read_auth().await?;
let auth_obj = auth
.as_object()
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
Ok(auth_obj.get(provider_id).cloned())
}
/// Remove provider auth entry from auth.json
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
if provider_id.is_empty() {
@@ -36,6 +36,13 @@ pub enum Scope {
Project,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderScope {
User,
Project,
Custom,
}
impl From<AgentScope> for Scope {
fn from(scope: AgentScope) -> Self {
match scope {
@@ -151,6 +158,23 @@ struct ConfigLayers {
paths: ConfigPaths,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderSources {
pub auth: ProviderSourceInfo,
pub user: ProviderSourceInfo,
pub project: ProviderSourceInfo,
pub custom: ProviderSourceInfo,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderSourceInfo {
pub exists: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
fn get_config_paths(working_directory: Option<&Path>) -> ConfigPaths {
ConfigPaths {
user: get_config_file(),
@@ -310,6 +334,135 @@ fn get_config_for_path<'a>(layers: &'a mut ConfigLayers, target_path: &Path) ->
&mut layers.user
}
pub async fn get_provider_sources(
provider_id: &str,
working_directory: Option<&Path>,
) -> Result<ProviderSources> {
if provider_id.trim().is_empty() {
return Err(anyhow!("Provider ID is required"));
}
let layers = read_config_layers(working_directory).await?;
let custom_exists = layers
.custom
.get("provider")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some()
|| layers
.custom
.get("providers")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some();
let project_exists = layers
.project
.get("provider")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some()
|| layers
.project
.get("providers")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some();
let user_exists = layers
.user
.get("provider")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some()
|| layers
.user
.get("providers")
.and_then(|v| v.as_object())
.and_then(|p| p.get(provider_id))
.is_some();
Ok(ProviderSources {
auth: ProviderSourceInfo { exists: false, path: None },
user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) },
project: ProviderSourceInfo {
exists: project_exists,
path: layers.paths.project.as_ref().map(|p| p.to_string_lossy().to_string()),
},
custom: ProviderSourceInfo {
exists: custom_exists,
path: layers.paths.custom.as_ref().map(|p| p.to_string_lossy().to_string()),
},
})
}
pub async fn remove_provider_config(
provider_id: &str,
working_directory: Option<&Path>,
scope: ProviderScope,
) -> Result<bool> {
if provider_id.trim().is_empty() {
return Err(anyhow!("Provider ID is required"));
}
let mut layers = read_config_layers(working_directory).await?;
let target_path = match scope {
ProviderScope::Project => layers
.paths
.project
.clone()
.ok_or_else(|| anyhow!("Project config path is not available"))?,
ProviderScope::Custom => layers
.paths
.custom
.clone()
.ok_or_else(|| anyhow!("Custom config path is not available"))?,
ProviderScope::User => layers.paths.user.clone(),
};
let config = get_config_for_path(&mut layers, &target_path);
let mut removed = false;
let mut remove_provider_key = false;
let mut remove_providers_key = false;
if let Some(provider_section) = config
.get_mut("provider")
.and_then(|v| v.as_object_mut())
{
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
remove_provider_key = true;
}
}
}
if let Some(provider_section) = config
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
remove_providers_key = true;
}
}
}
if !removed {
return Ok(false);
}
if remove_provider_key {
config.as_object_mut().map(|map| map.remove("provider"));
}
if remove_providers_key {
config.as_object_mut().map(|map| map.remove("providers"));
}
write_config_at(config, &target_path).await?;
Ok(true)
}
// ============== AGENT SCOPE HELPERS ==============
/// Get project-level agent directory path