diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index dc163a98..a0c98366 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -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; diff --git a/packages/desktop/src-tauri/src/opencode_auth.rs b/packages/desktop/src-tauri/src/opencode_auth.rs index bd843c61..c4eaa95b 100644 --- a/packages/desktop/src-tauri/src/opencode_auth.rs +++ b/packages/desktop/src-tauri/src/opencode_auth.rs @@ -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> { + 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 { if provider_id.is_empty() { diff --git a/packages/desktop/src-tauri/src/opencode_config.rs b/packages/desktop/src-tauri/src/opencode_config.rs index dfaa7e12..241b3971 100644 --- a/packages/desktop/src-tauri/src/opencode_config.rs +++ b/packages/desktop/src-tauri/src/opencode_config.rs @@ -36,6 +36,13 @@ pub enum Scope { Project, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderScope { + User, + Project, + Custom, +} + impl From 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, +} + 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 { + 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 { + 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 diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 00bffb41..85c2197e 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -51,6 +51,18 @@ interface ProviderOption { 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 => typeof value === 'object' && value !== null; @@ -142,6 +154,8 @@ export const ProvidersPage: React.FC = () => { const [candidateProviderId, setCandidateProviderId] = React.useState(''); const [providerSearchQuery, setProviderSearchQuery] = React.useState(''); const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); + const [providerSources, setProviderSources] = React.useState>({}); + const [showAuthPanel, setShowAuthPanel] = React.useState(false); React.useEffect(() => { if (!selectedProviderId && providers.length > 0) { @@ -247,7 +261,57 @@ export const ProvidersPage: React.FC = () => { } }, [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 selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined; const handleSaveApiKey = async (providerId: string) => { const apiKey = apiKeyInputs[providerId]?.trim() ?? ''; @@ -408,7 +472,7 @@ export const ProvidersPage: React.FC = () => { setAuthBusyKey(busyKey); try { - const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth`, { + const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }); @@ -743,9 +807,25 @@ export const ProvidersPage: React.FC = () => {
-

Authentication

+
+

Authentication

+ {selectedProviderId !== ADD_PROVIDER_ID && ( + + )} +
- {authLoading ? ( + {!showAuthPanel && selectedProviderId !== ADD_PROVIDER_ID ? ( +

+ Connected. Use Reconnect to update credentials. +

+ ) : authLoading ? (

Loading authentication methods…

) : (
@@ -777,18 +857,6 @@ export const ProvidersPage: React.FC = () => {

-
- -
- {oauthAuthMethods.length > 0 && (
{oauthAuthMethods.map((method, index) => { @@ -895,12 +963,33 @@ export const ProvidersPage: React.FC = () => { })}
)} - -
)} +
+

Connection

+ {selectedSources && (selectedSources.auth.exists || selectedSources.user.exists || selectedSources.project.exists || selectedSources.custom?.exists) && ( +
+ 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(', ')} +
+ )} + +
+

Models

diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index e8c46f88..58b048dc 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -3,8 +3,8 @@ import * as os from 'os'; import * as path from 'path'; import { spawn } from 'child_process'; 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 { removeProviderAuth } from './opencodeAuth'; +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 { getProviderAuth, removeProviderAuth } from './opencodeAuth'; import * as gitService from './gitService'; import { getSkillsCatalog, @@ -1293,13 +1293,31 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } - case 'api:provider/auth:delete': { - const { providerId } = (payload || {}) as { providerId?: string }; + case 'api:provider/auth:delete': { + const { providerId, scope } = (payload || {}) as { providerId?: string; scope?: string }; if (!providerId) { return { id, type, success: false, error: 'Provider ID is required' }; } + const normalizedScope = typeof scope === 'string' ? scope : 'auth'; 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) { 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': { const { command, args } = (payload || {}) as { command?: string; args?: unknown[] }; if (!command) { diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 6d6df31d..9f57ae8b 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -259,6 +259,13 @@ const readConfigLayers = (workingDirectory?: string) => { const readConfig = (workingDirectory?: string): Record => readConfigLayers(workingDirectory).mergedConfig; +const getConfigForPath = (layers: ReturnType, 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, filePath: string = CONFIG_FILE) => { if (fs.existsSync(filePath)) { const backupFile = `${filePath}.openchamber.backup`; @@ -732,6 +739,99 @@ export const updateCommand = (commandName: string, updates: Record { + const layers = readConfigLayers(workingDirectory); + const customProviders = isPlainObject((layers.customConfig as Record)?.provider) + ? (layers.customConfig as Record).provider as Record + : {}; + const customProvidersAlias = isPlainObject((layers.customConfig as Record)?.providers) + ? (layers.customConfig as Record).providers as Record + : {}; + const projectProviders = isPlainObject((layers.projectConfig as Record)?.provider) + ? (layers.projectConfig as Record).provider as Record + : {}; + const projectProvidersAlias = isPlainObject((layers.projectConfig as Record)?.providers) + ? (layers.projectConfig as Record).providers as Record + : {}; + const userProviders = isPlainObject((layers.userConfig as Record)?.provider) + ? (layers.userConfig as Record).provider as Record + : {}; + const userProvidersAlias = isPlainObject((layers.userConfig as Record)?.providers) + ? (layers.userConfig as Record).providers as Record + : {}; + + 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).provider) + ? (targetConfig as Record).provider as Record + : {}; + const providersConfig = isPlainObject((targetConfig as Record).providers) + ? (targetConfig as Record).providers as Record + : {}; + + 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).provider; + } else { + (targetConfig as Record).provider = providerConfig; + } + } + + if (removedProviders) { + delete providersConfig[providerId]; + if (Object.keys(providersConfig).length === 0) { + delete (targetConfig as Record).providers; + } else { + (targetConfig as Record).providers = providersConfig; + } + } + + writeConfig(targetConfig as Record, targetPath || CONFIG_FILE); + return true; +}; + export const deleteCommand = (commandName: string, workingDirectory?: string) => { let deleted = false; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index c8a3ab3e..ab2b5042 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -613,8 +613,22 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/); if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') { const providerId = decodeURIComponent(providerAuthMatch[1]); + const scope = url.searchParams.get('scope') || 'auth'; 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' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e80c39c8..de9f3817 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2536,7 +2536,9 @@ async function main(options = {}) { app.post('/api/push/subscribe', async (req, res) => { await ensurePushInitialized(); - const uiToken = getUiSessionTokenFromRequest(req); + const uiToken = uiAuthController?.ensureSessionToken + ? uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); if (!uiToken) { 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) => { await ensurePushInitialized(); - const uiToken = getUiSessionTokenFromRequest(req); + const uiToken = uiAuthController?.ensureSessionToken + ? uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } @@ -2597,7 +2601,9 @@ async function main(options = {}) { }); app.post('/api/push/visibility', (req, res) => { - const uiToken = getUiSessionTokenFromRequest(req); + const uiToken = uiAuthController?.ensureSessionToken + ? uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); if (!uiToken) { return res.status(401).json({ error: 'UI session missing' }); } @@ -3056,6 +3062,8 @@ async function main(options = {}) { createCommand, updateCommand, deleteCommand, + getProviderSources, + removeProviderConfig, AGENT_SCOPE, COMMAND_SCOPE } = await import('./lib/opencode-config.js'); @@ -3811,6 +3819,42 @@ async function main(options = {}) { 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) => { try { const { providerId } = req.params; @@ -3818,17 +3862,54 @@ async function main(options = {}) { return res.status(400).json({ error: 'Provider ID is required' }); } - const { removeProviderAuth } = await getAuthLibrary(); - const removed = removeProviderAuth(providerId); + const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth'; + 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({ success: true, removed, - requiresReload: true, + requiresReload: removed, message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', - reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, }); } catch (error) { console.error('Failed to disconnect provider:', error); diff --git a/packages/web/server/lib/opencode-config.js b/packages/web/server/lib/opencode-config.js index f75642bc..cec7952c 100644 --- a/packages/web/server/lib/opencode-config.js +++ b/packages/web/server/lib/opencode-config.js @@ -653,6 +653,19 @@ function readConfig(workingDirectory) { 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) { try { if (fs.existsSync(filePath)) { @@ -1314,6 +1327,90 @@ function updateCommand(commandName, updates, workingDirectory) { 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) { let deleted = false; @@ -1669,6 +1766,8 @@ export { deleteSkillSupportingFile, readConfig, writeConfig, + getProviderSources, + removeProviderConfig, AGENT_DIR, COMMAND_DIR, SKILL_DIR, diff --git a/packages/web/server/lib/ui-auth.js b/packages/web/server/lib/ui-auth.js index e742f966..2d15654b 100644 --- a/packages/web/server/lib/ui-auth.js +++ b/packages/web/server/lib/ui-auth.js @@ -81,6 +81,28 @@ export const createUiAuth = ({ const normalizedPassword = normalizePassword(password); 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 { enabled: false, requireAuth: (_req, _res, next) => next(), @@ -90,6 +112,7 @@ export const createUiAuth = ({ handleSessionCreate: (_req, res) => { res.status(400).json({ error: 'UI password not configured' }); }, + ensureSessionToken, dispose: () => { }, @@ -261,6 +284,10 @@ export const createUiAuth = ({ requireAuth, handleSessionStatus, handleSessionCreate, + ensureSessionToken: (req, _res) => { + const token = getTokenFromRequest(req); + return isSessionValid(token) ? token : null; + }, dispose, }; };