feat: provider config management (#193)

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

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