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:
committed by
GitHub
parent
d4f1d8abbf
commit
05caf4cc58
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user