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
+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,
};
};