From 66c5f0cdd488a6ecbb1ef094aaed94498e5f277f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 08:22:30 +0000 Subject: [PATCH] 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 --- .../sections/providers/ProvidersPage.tsx | 18 +++- .../providers/custom-provider-form.test.ts | 38 ++++++++ .../providers/custom-provider-form.ts | 27 +++++- packages/vscode/src/DOCUMENTATION.md | 2 +- .../src/opencodeConfig.providers.test.ts | 89 +++++++++++++++++++ packages/vscode/src/opencodeConfig.ts | 8 +- .../web/server/lib/opencode/DOCUMENTATION.md | 6 +- .../web/server/lib/opencode/providers.test.js | 89 +++++++++++++++++++ packages/web/server/lib/opencode/shared.js | 8 +- 9 files changed, 271 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index a9a64fe7..de947250 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -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(null); const [editingCustomFormInitial, setEditingCustomFormInitial] = React.useState(null); + const [editingCustomScope, setEditingCustomScope] = React.useState(null); const [customAuthFailureHint, setCustomAuthFailureHint] = React.useState(null); const [lastCustomPersistId, setLastCustomPersistId] = React.useState(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); }} > diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.test.ts b/packages/ui/src/components/sections/providers/custom-provider-form.test.ts index fd82237f..a8b5c930 100644 --- a/packages/ui/src/components/sections/providers/custom-provider-form.test.ts +++ b/packages/ui/src/components/sections/providers/custom-provider-form.test.ts @@ -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'); + }); }); diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.ts b/packages/ui/src/components/sections/providers/custom-provider-form.ts index d9437a01..d734288f 100644 --- a/packages/ui/src/components/sections/providers/custom-provider-form.ts +++ b/packages/ui/src/components/sections/providers/custom-provider-form.ts @@ -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', }; } diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 8bbaa3f6..9218e072 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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. diff --git a/packages/vscode/src/opencodeConfig.providers.test.ts b/packages/vscode/src/opencodeConfig.providers.test.ts index 23199263..d4e64e87 100644 --- a/packages/vscode/src/opencodeConfig.providers.test.ts +++ b/packages/vscode/src/opencodeConfig.providers.test.ts @@ -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; + } + } + }); }); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index e76a3d44..4771d98a 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -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 => { diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index aa8d811a..baa7256d 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js index c51f5a1f..b023bf87 100644 --- a/packages/web/server/lib/opencode/providers.test.js +++ b/packages/web/server/lib/opencode/providers.test.js @@ -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; + } + } + }); }); diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 8f499977..6df1faea 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -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, }; }