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
|
// Handle provider auth removal: DELETE /api/provider/:providerId/auth
|
||||||
if let Some(rest) = path.strip_prefix("/api/provider/") {
|
if let Some(rest) = path.strip_prefix("/api/provider/") {
|
||||||
if let Some(provider_id) = rest.strip_suffix("/auth") {
|
if let Some(provider_id) = rest.strip_suffix("/auth") {
|
||||||
@@ -2246,8 +2295,79 @@ async fn handle_config_routes(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
match opencode_auth::remove_provider_auth(trimmed).await {
|
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
|
||||||
|
.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) => {
|
Ok(removed) => {
|
||||||
|
if removed {
|
||||||
if let Err(resp) = refresh_opencode_after_config_change(
|
if let Err(resp) = refresh_opencode_after_config_change(
|
||||||
&state,
|
&state,
|
||||||
&format!("provider {} disconnected", trimmed),
|
&format!("provider {} disconnected", trimmed),
|
||||||
@@ -2256,18 +2376,19 @@ async fn handle_config_routes(
|
|||||||
{
|
{
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(json_response(
|
return Ok(json_response(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
ConfigActionResponse {
|
ConfigActionResponse {
|
||||||
success: true,
|
success: true,
|
||||||
requires_reload: true,
|
requires_reload: removed,
|
||||||
message: if removed {
|
message: if removed {
|
||||||
"Provider disconnected successfully".to_string()
|
"Provider disconnected successfully".to_string()
|
||||||
} else {
|
} else {
|
||||||
"Provider was not connected".to_string()
|
"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.ends_with("/auth")
|
||||||
&& origin_path != "/api/provider/auth"; // Exclude GET /api/provider/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/")
|
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/commands/")
|
||||||
|| origin_path.starts_with("/api/config/skills")
|
|| origin_path.starts_with("/api/config/skills")
|
||||||
|| origin_path == "/api/config/reload"
|
|| origin_path == "/api/config/reload"
|
||||||
|| is_provider_auth_delete;
|
|| is_provider_auth_delete
|
||||||
|
|| is_provider_source_get;
|
||||||
|
|
||||||
if is_desktop_config_route {
|
if is_desktop_config_route {
|
||||||
return handle_config_routes(state, &origin_path, method, req).await;
|
return handle_config_routes(state, &origin_path, method, req).await;
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ pub async fn write_auth(auth: &Value) -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// Remove provider auth entry from auth.json
|
||||||
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
||||||
if provider_id.is_empty() {
|
if provider_id.is_empty() {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ pub enum Scope {
|
|||||||
Project,
|
Project,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProviderScope {
|
||||||
|
User,
|
||||||
|
Project,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<AgentScope> for Scope {
|
impl From<AgentScope> for Scope {
|
||||||
fn from(scope: AgentScope) -> Self {
|
fn from(scope: AgentScope) -> Self {
|
||||||
match scope {
|
match scope {
|
||||||
@@ -151,6 +158,23 @@ struct ConfigLayers {
|
|||||||
paths: ConfigPaths,
|
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 {
|
fn get_config_paths(working_directory: Option<&Path>) -> ConfigPaths {
|
||||||
ConfigPaths {
|
ConfigPaths {
|
||||||
user: get_config_file(),
|
user: get_config_file(),
|
||||||
@@ -310,6 +334,135 @@ fn get_config_for_path<'a>(layers: &'a mut ConfigLayers, target_path: &Path) ->
|
|||||||
&mut layers.user
|
&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 ==============
|
// ============== AGENT SCOPE HELPERS ==============
|
||||||
|
|
||||||
/// Get project-level agent directory path
|
/// Get project-level agent directory path
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ interface ProviderOption {
|
|||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderSourceInfo {
|
||||||
|
exists: boolean;
|
||||||
|
path?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderSources {
|
||||||
|
auth: ProviderSourceInfo;
|
||||||
|
user: ProviderSourceInfo;
|
||||||
|
project: ProviderSourceInfo;
|
||||||
|
custom?: ProviderSourceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === 'object' && value !== null;
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
@@ -142,6 +154,8 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const [candidateProviderId, setCandidateProviderId] = React.useState('');
|
const [candidateProviderId, setCandidateProviderId] = React.useState('');
|
||||||
const [providerSearchQuery, setProviderSearchQuery] = React.useState('');
|
const [providerSearchQuery, setProviderSearchQuery] = React.useState('');
|
||||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||||
|
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||||
|
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedProviderId && providers.length > 0) {
|
if (!selectedProviderId && providers.length > 0) {
|
||||||
@@ -247,7 +261,57 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
|
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedProviderId === ADD_PROVIDER_ID) {
|
||||||
|
setShowAuthPanel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAuthPanel(false);
|
||||||
|
}, [selectedProviderId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadSources = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || 'Failed to load provider sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
|
||||||
|
if (!cancelled && sources) {
|
||||||
|
setProviderSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[selectedProviderId]: sources,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Failed to load provider sources:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSources();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedProviderId]);
|
||||||
|
|
||||||
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
|
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
|
||||||
|
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
|
||||||
|
|
||||||
const handleSaveApiKey = async (providerId: string) => {
|
const handleSaveApiKey = async (providerId: string) => {
|
||||||
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
|
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
|
||||||
@@ -408,7 +472,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
setAuthBusyKey(busyKey);
|
setAuthBusyKey(busyKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth`, {
|
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
@@ -743,9 +807,25 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
|
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
|
||||||
|
{selectedProviderId !== ADD_PROVIDER_ID && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{showAuthPanel ? 'Hide' : 'Reconnect'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{authLoading ? (
|
{!showAuthPanel && selectedProviderId !== ADD_PROVIDER_ID ? (
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
Connected. Use Reconnect to update credentials.
|
||||||
|
</p>
|
||||||
|
) : authLoading ? (
|
||||||
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p>
|
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -777,18 +857,6 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border/40">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleDisconnectProvider(selectedProvider.id)}
|
|
||||||
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
|
||||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{oauthAuthMethods.length > 0 && (
|
{oauthAuthMethods.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{oauthAuthMethods.map((method, index) => {
|
{oauthAuthMethods.map((method, index) => {
|
||||||
@@ -895,12 +963,33 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="typography-ui-header font-semibold text-foreground">Connection</h2>
|
||||||
|
{selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) && (
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
Configured in: {[
|
||||||
|
selectedSources.auth.exists ? 'auth credentials' : null,
|
||||||
|
selectedSources.user.exists ? 'user config' : null,
|
||||||
|
selectedSources.project.exists ? 'project config' : null,
|
||||||
|
selectedSources.custom?.exists ? 'custom config' : null,
|
||||||
|
].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleDisconnectProvider(selectedProvider.id)}
|
||||||
|
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
||||||
|
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
|
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { type OpenCodeManager } from './opencode';
|
import { type OpenCodeManager } from './opencode';
|
||||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
|
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
|
||||||
import { removeProviderAuth } from './opencodeAuth';
|
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||||
import * as gitService from './gitService';
|
import * as gitService from './gitService';
|
||||||
import {
|
import {
|
||||||
getSkillsCatalog,
|
getSkillsCatalog,
|
||||||
@@ -1294,12 +1294,30 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:provider/auth:delete': {
|
case 'api:provider/auth:delete': {
|
||||||
const { providerId } = (payload || {}) as { providerId?: string };
|
const { providerId, scope } = (payload || {}) as { providerId?: string; scope?: string };
|
||||||
if (!providerId) {
|
if (!providerId) {
|
||||||
return { id, type, success: false, error: 'Provider ID is required' };
|
return { id, type, success: false, error: 'Provider ID is required' };
|
||||||
}
|
}
|
||||||
|
const normalizedScope = typeof scope === 'string' ? scope : 'auth';
|
||||||
try {
|
try {
|
||||||
const removed = removeProviderAuth(providerId);
|
let removed = false;
|
||||||
|
if (normalizedScope === 'auth') {
|
||||||
|
removed = removeProviderAuth(providerId);
|
||||||
|
} else if (normalizedScope === 'user' || normalizedScope === 'project' || normalizedScope === 'custom') {
|
||||||
|
removed = removeProviderConfig(providerId, ctx?.manager?.getWorkingDirectory(), normalizedScope);
|
||||||
|
} else if (normalizedScope === 'all') {
|
||||||
|
const workingDirectory = ctx?.manager?.getWorkingDirectory();
|
||||||
|
const authRemoved = removeProviderAuth(providerId);
|
||||||
|
const userRemoved = removeProviderConfig(providerId, workingDirectory, 'user');
|
||||||
|
const projectRemoved = workingDirectory
|
||||||
|
? removeProviderConfig(providerId, workingDirectory, 'project')
|
||||||
|
: false;
|
||||||
|
const customRemoved = removeProviderConfig(providerId, workingDirectory, 'custom');
|
||||||
|
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
||||||
|
} else {
|
||||||
|
return { id, type, success: false, error: 'Invalid scope' };
|
||||||
|
}
|
||||||
|
|
||||||
if (removed) {
|
if (removed) {
|
||||||
await ctx?.manager?.restart();
|
await ctx?.manager?.restart();
|
||||||
}
|
}
|
||||||
@@ -1323,6 +1341,23 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:provider/source:get': {
|
||||||
|
const { providerId } = (payload || {}) as { providerId?: string };
|
||||||
|
if (!providerId) {
|
||||||
|
return { id, type, success: false, error: 'Provider ID is required' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const sources = getProviderSources(providerId, ctx?.manager?.getWorkingDirectory());
|
||||||
|
const auth = getProviderAuth(providerId);
|
||||||
|
sources.auth.exists = Boolean(auth);
|
||||||
|
return { id, type, success: true, data: { providerId, sources } };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return { id, type, success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
case 'vscode:command': {
|
case 'vscode:command': {
|
||||||
const { command, args } = (payload || {}) as { command?: string; args?: unknown[] };
|
const { command, args } = (payload || {}) as { command?: string; args?: unknown[] };
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
|||||||
@@ -259,6 +259,13 @@ const readConfigLayers = (workingDirectory?: string) => {
|
|||||||
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
|
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
|
||||||
readConfigLayers(workingDirectory).mergedConfig;
|
readConfigLayers(workingDirectory).mergedConfig;
|
||||||
|
|
||||||
|
const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPath?: string | null) => {
|
||||||
|
if (!targetPath) return layers.userConfig;
|
||||||
|
if (layers.paths.customPath && targetPath === layers.paths.customPath) return layers.customConfig;
|
||||||
|
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) return layers.projectConfig;
|
||||||
|
return layers.userConfig;
|
||||||
|
};
|
||||||
|
|
||||||
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const backupFile = `${filePath}.openchamber.backup`;
|
const backupFile = `${filePath}.openchamber.backup`;
|
||||||
@@ -732,6 +739,99 @@ export const updateCommand = (commandName: string, updates: Record<string, unkno
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getProviderSources = (providerId: string, workingDirectory?: string) => {
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const customProviders = isPlainObject((layers.customConfig as Record<string, unknown>)?.provider)
|
||||||
|
? (layers.customConfig as Record<string, unknown>).provider as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const customProvidersAlias = isPlainObject((layers.customConfig as Record<string, unknown>)?.providers)
|
||||||
|
? (layers.customConfig as Record<string, unknown>).providers as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const projectProviders = isPlainObject((layers.projectConfig as Record<string, unknown>)?.provider)
|
||||||
|
? (layers.projectConfig as Record<string, unknown>).provider as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const projectProvidersAlias = isPlainObject((layers.projectConfig as Record<string, unknown>)?.providers)
|
||||||
|
? (layers.projectConfig as Record<string, unknown>).providers as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const userProviders = isPlainObject((layers.userConfig as Record<string, unknown>)?.provider)
|
||||||
|
? (layers.userConfig as Record<string, unknown>).provider as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const userProvidersAlias = isPlainObject((layers.userConfig as Record<string, unknown>)?.providers)
|
||||||
|
? (layers.userConfig as Record<string, unknown>).providers as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const customExists = Object.prototype.hasOwnProperty.call(customProviders, providerId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId);
|
||||||
|
const projectExists = Object.prototype.hasOwnProperty.call(projectProviders, providerId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId);
|
||||||
|
const userExists = Object.prototype.hasOwnProperty.call(userProviders, providerId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth: { exists: false },
|
||||||
|
user: { exists: userExists, path: layers.paths.userPath },
|
||||||
|
project: { exists: projectExists, path: layers.paths.projectPath ?? null },
|
||||||
|
custom: { exists: customExists, path: layers.paths.customPath },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeProviderConfig = (providerId: string, workingDirectory?: string, scope: 'user' | 'project' | 'custom' = 'user') => {
|
||||||
|
if (!providerId) throw new Error('Provider ID is required');
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
let targetPath: string | null | undefined = layers.paths.userPath;
|
||||||
|
|
||||||
|
if (scope === 'project') {
|
||||||
|
if (!workingDirectory) {
|
||||||
|
throw new Error('Working directory is required for project scope');
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.projectPath ?? targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'custom') {
|
||||||
|
if (!layers.paths.customPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.customPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConfig = getConfigForPath(layers, targetPath);
|
||||||
|
const providerConfig = isPlainObject((targetConfig as Record<string, unknown>).provider)
|
||||||
|
? (targetConfig as Record<string, unknown>).provider as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const providersConfig = isPlainObject((targetConfig as Record<string, unknown>).providers)
|
||||||
|
? (targetConfig as Record<string, unknown>).providers as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
||||||
|
const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
||||||
|
|
||||||
|
if (!removedProvider && !removedProviders) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProvider) {
|
||||||
|
delete providerConfig[providerId];
|
||||||
|
if (Object.keys(providerConfig).length === 0) {
|
||||||
|
delete (targetConfig as Record<string, unknown>).provider;
|
||||||
|
} else {
|
||||||
|
(targetConfig as Record<string, unknown>).provider = providerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProviders) {
|
||||||
|
delete providersConfig[providerId];
|
||||||
|
if (Object.keys(providersConfig).length === 0) {
|
||||||
|
delete (targetConfig as Record<string, unknown>).providers;
|
||||||
|
} else {
|
||||||
|
(targetConfig as Record<string, unknown>).providers = providersConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeConfig(targetConfig as Record<string, unknown>, targetPath || CONFIG_FILE);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
|
||||||
let deleted = false;
|
let deleted = false;
|
||||||
|
|
||||||
|
|||||||
@@ -613,8 +613,22 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
|
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
|
||||||
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
|
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
|
||||||
const providerId = decodeURIComponent(providerAuthMatch[1]);
|
const providerId = decodeURIComponent(providerAuthMatch[1]);
|
||||||
|
const scope = url.searchParams.get('scope') || 'auth';
|
||||||
try {
|
try {
|
||||||
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId });
|
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope });
|
||||||
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle provider source lookup: GET /api/provider/:providerId/source
|
||||||
|
const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/);
|
||||||
|
if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
|
||||||
|
const providerId = decodeURIComponent(providerSourceMatch[1]);
|
||||||
|
try {
|
||||||
|
const data = await sendBridgeMessage('api:provider/source:get', { providerId });
|
||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -2536,7 +2536,9 @@ async function main(options = {}) {
|
|||||||
app.post('/api/push/subscribe', async (req, res) => {
|
app.post('/api/push/subscribe', async (req, res) => {
|
||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
|
||||||
const uiToken = getUiSessionTokenFromRequest(req);
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
|
? uiAuthController.ensureSessionToken(req, res)
|
||||||
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
}
|
}
|
||||||
@@ -2582,7 +2584,9 @@ async function main(options = {}) {
|
|||||||
app.delete('/api/push/subscribe', async (req, res) => {
|
app.delete('/api/push/subscribe', async (req, res) => {
|
||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
|
||||||
const uiToken = getUiSessionTokenFromRequest(req);
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
|
? uiAuthController.ensureSessionToken(req, res)
|
||||||
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
}
|
}
|
||||||
@@ -2597,7 +2601,9 @@ async function main(options = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/push/visibility', (req, res) => {
|
app.post('/api/push/visibility', (req, res) => {
|
||||||
const uiToken = getUiSessionTokenFromRequest(req);
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
|
? uiAuthController.ensureSessionToken(req, res)
|
||||||
|
: getUiSessionTokenFromRequest(req);
|
||||||
if (!uiToken) {
|
if (!uiToken) {
|
||||||
return res.status(401).json({ error: 'UI session missing' });
|
return res.status(401).json({ error: 'UI session missing' });
|
||||||
}
|
}
|
||||||
@@ -3056,6 +3062,8 @@ async function main(options = {}) {
|
|||||||
createCommand,
|
createCommand,
|
||||||
updateCommand,
|
updateCommand,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
|
getProviderSources,
|
||||||
|
removeProviderConfig,
|
||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
COMMAND_SCOPE
|
COMMAND_SCOPE
|
||||||
} = await import('./lib/opencode-config.js');
|
} = await import('./lib/opencode-config.js');
|
||||||
@@ -3811,6 +3819,42 @@ async function main(options = {}) {
|
|||||||
return authLibrary;
|
return authLibrary;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { providerId } = req.params;
|
||||||
|
if (!providerId) {
|
||||||
|
return res.status(400).json({ error: 'Provider ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||||
|
const queryDirectory = Array.isArray(req.query?.directory)
|
||||||
|
? req.query.directory[0]
|
||||||
|
: req.query?.directory;
|
||||||
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||||
|
|
||||||
|
let directory = null;
|
||||||
|
const resolved = await resolveProjectDirectory(req);
|
||||||
|
if (resolved.directory) {
|
||||||
|
directory = resolved.directory;
|
||||||
|
} else if (requestedDirectory) {
|
||||||
|
return res.status(400).json({ error: resolved.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = getProviderSources(providerId, directory);
|
||||||
|
const { getProviderAuth } = await getAuthLibrary();
|
||||||
|
const auth = getProviderAuth(providerId);
|
||||||
|
sources.sources.auth.exists = Boolean(auth);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
providerId,
|
||||||
|
sources: sources.sources,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get provider sources:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to get provider sources' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { providerId } = req.params;
|
const { providerId } = req.params;
|
||||||
@@ -3818,17 +3862,54 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ error: 'Provider ID is required' });
|
return res.status(400).json({ error: 'Provider ID is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { removeProviderAuth } = await getAuthLibrary();
|
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
|
||||||
const removed = removeProviderAuth(providerId);
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||||
|
const queryDirectory = Array.isArray(req.query?.directory)
|
||||||
|
? req.query.directory[0]
|
||||||
|
: req.query?.directory;
|
||||||
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||||
|
let directory = null;
|
||||||
|
|
||||||
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected`);
|
if (scope === 'project' || requestedDirectory) {
|
||||||
|
const resolved = await resolveProjectDirectory(req);
|
||||||
|
if (!resolved.directory) {
|
||||||
|
return res.status(400).json({ error: resolved.error });
|
||||||
|
}
|
||||||
|
directory = resolved.directory;
|
||||||
|
} else {
|
||||||
|
const resolved = await resolveProjectDirectory(req);
|
||||||
|
if (resolved.directory) {
|
||||||
|
directory = resolved.directory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = false;
|
||||||
|
if (scope === 'auth') {
|
||||||
|
const { removeProviderAuth } = await getAuthLibrary();
|
||||||
|
removed = removeProviderAuth(providerId);
|
||||||
|
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
|
||||||
|
removed = removeProviderConfig(providerId, directory, scope);
|
||||||
|
} else if (scope === 'all') {
|
||||||
|
const { removeProviderAuth } = await getAuthLibrary();
|
||||||
|
const authRemoved = removeProviderAuth(providerId);
|
||||||
|
const userRemoved = removeProviderConfig(providerId, directory, 'user');
|
||||||
|
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
|
||||||
|
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
|
||||||
|
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Invalid scope' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed) {
|
||||||
|
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
removed,
|
removed,
|
||||||
requiresReload: true,
|
requiresReload: removed,
|
||||||
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
||||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disconnect provider:', error);
|
console.error('Failed to disconnect provider:', error);
|
||||||
|
|||||||
@@ -653,6 +653,19 @@ function readConfig(workingDirectory) {
|
|||||||
return readConfigLayers(workingDirectory).mergedConfig;
|
return readConfigLayers(workingDirectory).mergedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getConfigForPath(layers, targetPath) {
|
||||||
|
if (!targetPath) {
|
||||||
|
return layers.userConfig;
|
||||||
|
}
|
||||||
|
if (layers.paths.customPath && targetPath === layers.paths.customPath) {
|
||||||
|
return layers.customConfig;
|
||||||
|
}
|
||||||
|
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
|
||||||
|
return layers.projectConfig;
|
||||||
|
}
|
||||||
|
return layers.userConfig;
|
||||||
|
}
|
||||||
|
|
||||||
function writeConfig(config, filePath = CONFIG_FILE) {
|
function writeConfig(config, filePath = CONFIG_FILE) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
@@ -1314,6 +1327,90 @@ function updateCommand(commandName, updates, workingDirectory) {
|
|||||||
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
console.log(`Updated command: ${commandName} (scope: ${targetScope}, md: ${mdModified}, json: ${jsonModified})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProviderSources(providerId, workingDirectory) {
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||||
|
|
||||||
|
const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {};
|
||||||
|
const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {};
|
||||||
|
const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {};
|
||||||
|
const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {};
|
||||||
|
const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {};
|
||||||
|
const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {};
|
||||||
|
|
||||||
|
const customExists =
|
||||||
|
(customProviders && Object.prototype.hasOwnProperty.call(customProviders, providerId)) ||
|
||||||
|
(customProvidersAlias && Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId));
|
||||||
|
const projectExists =
|
||||||
|
(projectProviders && Object.prototype.hasOwnProperty.call(projectProviders, providerId)) ||
|
||||||
|
(projectProvidersAlias && Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId));
|
||||||
|
const userExists =
|
||||||
|
(userProviders && Object.prototype.hasOwnProperty.call(userProviders, providerId)) ||
|
||||||
|
(userProvidersAlias && Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: {
|
||||||
|
auth: { exists: false },
|
||||||
|
user: { exists: userExists, path: paths.userPath },
|
||||||
|
project: { exists: projectExists, path: paths.projectPath || null },
|
||||||
|
custom: { exists: customExists, path: paths.customPath }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||||
|
if (!providerId || typeof providerId !== 'string') {
|
||||||
|
throw new Error('Provider ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const layers = readConfigLayers(workingDirectory);
|
||||||
|
let targetPath = layers.paths.userPath;
|
||||||
|
|
||||||
|
if (scope === 'project') {
|
||||||
|
if (!workingDirectory) {
|
||||||
|
throw new Error('Working directory is required for project scope');
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.projectPath || targetPath;
|
||||||
|
} else if (scope === 'custom') {
|
||||||
|
if (!layers.paths.customPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
targetPath = layers.paths.customPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConfig = getConfigForPath(layers, targetPath);
|
||||||
|
const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {};
|
||||||
|
const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {};
|
||||||
|
const removedProvider = providerConfig && Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
||||||
|
const removedProviders = providersConfig && Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
||||||
|
|
||||||
|
if (!removedProvider && !removedProviders) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProvider) {
|
||||||
|
delete providerConfig[providerId];
|
||||||
|
if (Object.keys(providerConfig).length === 0) {
|
||||||
|
delete targetConfig.provider;
|
||||||
|
} else {
|
||||||
|
targetConfig.provider = providerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedProviders) {
|
||||||
|
delete providersConfig[providerId];
|
||||||
|
if (Object.keys(providersConfig).length === 0) {
|
||||||
|
delete targetConfig.providers;
|
||||||
|
} else {
|
||||||
|
targetConfig.providers = providersConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeConfig(targetConfig, targetPath || CONFIG_FILE);
|
||||||
|
console.log(`Removed provider ${providerId} from config: ${targetPath}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function deleteCommand(commandName, workingDirectory) {
|
function deleteCommand(commandName, workingDirectory) {
|
||||||
let deleted = false;
|
let deleted = false;
|
||||||
|
|
||||||
@@ -1669,6 +1766,8 @@ export {
|
|||||||
deleteSkillSupportingFile,
|
deleteSkillSupportingFile,
|
||||||
readConfig,
|
readConfig,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
|
getProviderSources,
|
||||||
|
removeProviderConfig,
|
||||||
AGENT_DIR,
|
AGENT_DIR,
|
||||||
COMMAND_DIR,
|
COMMAND_DIR,
|
||||||
SKILL_DIR,
|
SKILL_DIR,
|
||||||
|
|||||||
@@ -81,6 +81,28 @@ export const createUiAuth = ({
|
|||||||
const normalizedPassword = normalizePassword(password);
|
const normalizedPassword = normalizePassword(password);
|
||||||
|
|
||||||
if (!normalizedPassword) {
|
if (!normalizedPassword) {
|
||||||
|
const setSessionCookie = (req, res, token) => {
|
||||||
|
const secure = isSecureRequest(req);
|
||||||
|
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
||||||
|
const header = buildCookie({
|
||||||
|
name: cookieName,
|
||||||
|
value: encodeURIComponent(token),
|
||||||
|
maxAge: maxAgeSeconds,
|
||||||
|
secure,
|
||||||
|
});
|
||||||
|
res.setHeader('Set-Cookie', header);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSessionToken = (req, res) => {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
if (cookies[cookieName]) {
|
||||||
|
return cookies[cookieName];
|
||||||
|
}
|
||||||
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
|
setSessionCookie(req, res, token);
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
requireAuth: (_req, _res, next) => next(),
|
requireAuth: (_req, _res, next) => next(),
|
||||||
@@ -90,6 +112,7 @@ export const createUiAuth = ({
|
|||||||
handleSessionCreate: (_req, res) => {
|
handleSessionCreate: (_req, res) => {
|
||||||
res.status(400).json({ error: 'UI password not configured' });
|
res.status(400).json({ error: 'UI password not configured' });
|
||||||
},
|
},
|
||||||
|
ensureSessionToken,
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -261,6 +284,10 @@ export const createUiAuth = ({
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
handleSessionStatus,
|
handleSessionStatus,
|
||||||
handleSessionCreate,
|
handleSessionCreate,
|
||||||
|
ensureSessionToken: (req, _res) => {
|
||||||
|
const token = getTokenFromRequest(req);
|
||||||
|
return isSessionValid(token) ? token : null;
|
||||||
|
},
|
||||||
dispose,
|
dispose,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user