fix custom provider edit to preserve config scope

Derive the effective OpenCode config layer (custom > project > user) from
provider sources and send it on PUT /api/provider so project/custom edits
update that layer instead of creating a global user override. Resolve
OPENCODE_CONFIG at call time and add UI/web/VS Code coverage for scoped
upserts.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 08:22:30 +00:00
co-authored by Serhii Dziupin
parent b7c09ee137
commit 66c5f0cdd4
9 changed files with 271 additions and 14 deletions
@@ -33,8 +33,10 @@ import {
CUSTOM_PROVIDER_ID,
isConfigDefinedCustomProvider,
providerToCustomFormState,
resolveProviderConfigScope,
type CustomProviderFormState,
type CustomProviderPersistPlan,
type ProviderConfigScope,
} from './custom-provider-form';
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
@@ -184,6 +186,7 @@ export const ProvidersPage: React.FC = () => {
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
const [editingCustomProviderId, setEditingCustomProviderId] = React.useState<string | null>(null);
const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState<CustomProviderFormState | null>(null);
const [editingCustomScope, setEditingCustomScope] = React.useState<ProviderConfigScope | null>(null);
const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState<string | null>(null);
const [lastCustomPersistId, setLastCustomPersistId] = React.useState<string | null>(null);
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
@@ -306,6 +309,7 @@ export const ProvidersPage: React.FC = () => {
setShowAuthPanel(true);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
return;
}
@@ -314,6 +318,7 @@ export const ProvidersPage: React.FC = () => {
if (editingCustomProviderId && editingCustomProviderId !== selectedProviderId) {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
}
}, [selectedProviderId, editingCustomProviderId]);
@@ -411,7 +416,14 @@ export const ProvidersPage: React.FC = () => {
}
}
const upsertBody = buildProviderUpsertRequest(plan);
const upsertBody = buildProviderUpsertRequest(plan, {
// Create defaults to user. Edit must rewrite the winning config layer
// (custom > project > user) so project/custom providers are not copied
// into a global user override.
scope: editingCustomProviderId
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
method: 'PUT',
headers: {
@@ -432,6 +444,7 @@ export const ProvidersPage: React.FC = () => {
setCandidateProviderId('');
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' });
@@ -593,6 +606,7 @@ export const ProvidersPage: React.FC = () => {
await handleDisconnectProvider(providerId);
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
setCandidateProviderId('');
@@ -945,6 +959,7 @@ export const ProvidersPage: React.FC = () => {
onCancel={() => {
setEditingCustomProviderId(null);
setEditingCustomFormInitial(null);
setEditingCustomScope(null);
setCustomAuthFailureHint(null);
setLastCustomPersistId(null);
}}
@@ -975,6 +990,7 @@ export const ProvidersPage: React.FC = () => {
onClick={() => {
setCustomAuthFailureHint(null);
setEditingCustomFormInitial(providerToCustomFormState(selectedProvider));
setEditingCustomScope(resolveProviderConfigScope(selectedSources));
setEditingCustomProviderId(selectedProvider.id);
}}
>
@@ -5,6 +5,7 @@ import {
isConfigDefinedCustomProvider,
isCustomOpenAICompatibleProvider,
providerToCustomFormState,
resolveProviderConfigScope,
validateCustomProvider,
type CustomProviderConfig,
type CustomProviderFormState,
@@ -203,9 +204,22 @@ describe('request construction', () => {
expect(buildProviderUpsertRequest(plan)).toEqual({
providerID: 'custom-provider',
config: plan.config,
scope: 'user',
});
});
test('includes explicit project/custom scope on upsert requests', () => {
const validated = validateCustomProvider({
form: baseForm(),
t,
existingProviderIDs: new Set(),
});
const plan = validated.result!;
expect(buildProviderUpsertRequest(plan, { scope: 'project' }).scope).toBe('project');
expect(buildProviderUpsertRequest(plan, { scope: 'custom' }).scope).toBe('custom');
});
test('omits auth.set when using env credentials', () => {
const validated = validateCustomProvider({
form: baseForm({ apiKey: '{env:MY_KEY}' }),
@@ -309,4 +323,28 @@ describe('provider edit helpers', () => {
project: { exists: false },
})).toBe(true);
});
test('resolveProviderConfigScope follows custom > project > user precedence', () => {
expect(resolveProviderConfigScope(undefined)).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: false },
custom: { exists: false },
})).toBe('user');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: false },
})).toBe('project');
expect(resolveProviderConfigScope({
user: { exists: true },
project: { exists: true },
custom: { exists: true },
})).toBe('custom');
expect(resolveProviderConfigScope({
user: { exists: false },
project: { exists: false },
custom: { exists: true },
})).toBe('custom');
});
});
@@ -169,6 +169,8 @@ export type ProviderConfigSourcesLike = {
custom?: { exists?: boolean };
};
export type ProviderConfigScope = 'user' | 'project' | 'custom';
/**
* True when a provider both looks OpenAI-compatible-custom and is defined in a
* user/project/custom OpenCode config layer. Catalog-only providers often share
@@ -187,6 +189,22 @@ export function isConfigDefinedCustomProvider(
return inConfigLayer && isCustomOpenAICompatibleProvider(provider);
}
/**
* Effective writable config layer for a provider, matching OpenCode merge
* precedence: custom > project > user.
*/
export function resolveProviderConfigScope(
sources: ProviderConfigSourcesLike | null | undefined,
): ProviderConfigScope {
if (sources?.custom?.exists) {
return 'custom';
}
if (sources?.project?.exists) {
return 'project';
}
return 'user';
}
export function providerToCustomFormState(provider: ProviderLikeForCustomForm): CustomProviderFormState {
const options = provider.options && typeof provider.options === 'object' ? provider.options : {};
const baseURL = typeof options.baseURL === 'string' ? options.baseURL : '';
@@ -373,13 +391,20 @@ export function buildAuthSetRequest(plan: CustomProviderPersistPlan): {
/**
* Builds the OpenChamber provider upsert request body (config persistence).
* `scope` selects the OpenCode config layer (user/project/custom). Create
* defaults to user; edit must pass the provider's effective existing layer.
*/
export function buildProviderUpsertRequest(plan: CustomProviderPersistPlan): {
export function buildProviderUpsertRequest(
plan: CustomProviderPersistPlan,
options?: { scope?: ProviderConfigScope },
): {
providerID: string;
config: CustomProviderConfig;
scope: ProviderConfigScope;
} {
return {
providerID: plan.providerID,
config: plan.config,
scope: options?.scope ?? 'user',
};
}
+1 -1
View File
@@ -61,7 +61,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config; requires `env` or stored auth; secrets via OpenCode auth API).
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API).
- `opencode-upgrade-runtime.ts`
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
@@ -173,4 +173,93 @@ describe('custom provider config persistence (VS Code parity)', () => {
assert.equal(result.providerId, 'keyed-provider');
assert.equal(result.config.env, undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
assert.deepEqual(written.provider[providerId], {
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.project.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.custom.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
assert.equal(written.provider[providerId].name, 'Custom Scoped Updated');
assert.equal(written.provider[providerId].options.baseURL, 'https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
assert.equal(sources.custom.exists, true);
assert.equal(sources.user.exists, false);
assert.equal(sources.project.exists, false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
assert.equal(userConfig.provider?.[providerId], undefined);
assert.equal(userConfig.providers?.[providerId], undefined);
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
+4 -4
View File
@@ -10,9 +10,6 @@ const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
const SNIPPET_EXTENSION = '.md';
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
@@ -541,7 +538,10 @@ const getConfigPaths = (workingDirectory?: string) => ({
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
});
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
@@ -59,12 +59,12 @@ This module provides OpenCode server integration utilities for the web server ru
## Public exports (providers.js)
- `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider.
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`).
- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override.
- `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`).
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
## Public exports (shared.js)
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `ensureDirs()`: Creates required OpenCode directories.
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -88,7 +88,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
- `POST /api/opencode/directory`
- `GET /api/provider/:providerId/source`
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers; secrets stay in auth via the OpenCode auth API)
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers via `scope`; secrets stay in auth via the OpenCode auth API)
- `DELETE /api/provider/:providerId/auth`
- Owns lazy auth library loading for provider auth checks/removal.
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
@@ -169,4 +169,93 @@ describe('custom provider config persistence', () => {
expect(result.providerId).toBe('keyed-provider');
expect(result.config.env).toEqual(undefined);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
expect(written.provider[providerId]).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.custom.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.custom.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.project.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
+4 -4
View File
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}