diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fe638f97..f6345cae 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -58,7 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r - `bridge-config-runtime.ts` - Config and skills message handlers (`api:config/*`). - Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`). - - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, and writes still refuse to overwrite the broken file. + - OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file. - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts index c12572e8..7c696696 100644 --- a/packages/vscode/src/opencodeConfig.config-parse.test.ts +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { updateMcpConfig } from './opencodeConfig'; +import { listPluginEntries, updateMcpConfig } from './opencodeConfig'; const PARTIAL_PARSE_CONFIG = [ '{', @@ -94,6 +94,29 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => { assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG); }); + test('returns an empty object for a comment-only config file', () => { + const configPath = path.join(tempDir, 'comments.jsonc'); + fs.writeFileSync(configPath, '// placeholder\n/* still empty */\n', 'utf8'); + process.env.OPENCODE_CONFIG = configPath; + + assert.deepEqual(listPluginEntries(), []); + }); + + test('lists custom-layer plugins when a project layer is unparseable', () => { + const customPath = path.join(tempDir, 'custom.jsonc'); + const projectDir = path.join(tempDir, 'project'); + const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc'); + fs.writeFileSync(customPath, VALID_CONFIG, 'utf8'); + fs.mkdirSync(path.dirname(projectFile), { recursive: true }); + fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8'); + process.env.OPENCODE_CONFIG = customPath; + + const specs = listPluginEntries(projectDir).map((entry) => entry.spec); + assert.deepEqual(specs, ['opencode-see-image']); + assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG); + assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false); + }); + test('keeps a valid custom layer writable when a project layer is unparseable', () => { const customPath = path.join(tempDir, 'custom.jsonc'); const projectDir = path.join(tempDir, 'project'); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 50a40082..66142c36 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -926,10 +926,10 @@ const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope const projectPath = getProjectConfigPath(workingDirectory || undefined); return [ customPath - ? { scope: 'user', path: customPath, config: readConfigFile(customPath) } - : { scope: 'user', path: userPath, config: readConfigFile(userPath) }, + ? { scope: 'user', path: customPath, config: readConfigLayer(customPath).config } + : { scope: 'user', path: userPath, config: readConfigLayer(userPath).config }, ...(projectPath - ? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }] + ? [{ scope: 'project' as const, path: projectPath, config: readConfigLayer(projectPath).config }] : []), ]; }; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index cb35ed11..e783bb36 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -72,6 +72,7 @@ This module provides OpenCode server integration utilities for the web server ru - `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter. - `getConfigPaths(workingDirectory)`, `readConfigLayers(workingDirectory)`, `readConfig(workingDirectory)`: Config file operations with layer merging (user, project, custom). `readConfigLayers` isolates `INVALID_JSONC` per layer: a broken file is omitted from the merge (`{}` for that layer only), recorded on `layerErrors`, and does not block valid sibling layers. Writes still refuse to overwrite the broken file. - `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files (no JSON value) return `{}`. A `jsonc-parser` error that produces a partial or non-object tree throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config). +- `readConfigLayer(filePath)`: Same parse as `readConfigFile`, but isolates `INVALID_JSONC` to `{ config: {}, error }` so plugin/MCP/agent readers can skip one broken layer without aborting valid siblings. Writes still refuse to overwrite the broken file. - `writeConfig(config, filePath)`: Writes config with automatic backup. Refuses to overwrite an existing non-empty file that fails the same JSONC parse check. - `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry. A failed custom or user layer throws `INVALID_JSONC` instead of treating that file as empty. A failed project layer is skipped so a valid user/custom entry can still be found. - `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer. diff --git a/packages/web/server/lib/opencode/plugins.js b/packages/web/server/lib/opencode/plugins.js index 3eebfce9..143298fd 100644 --- a/packages/web/server/lib/opencode/plugins.js +++ b/packages/web/server/lib/opencode/plugins.js @@ -4,6 +4,7 @@ import path from 'path'; import { AGENT_SCOPE, readConfigFile, + readConfigLayer, writeConfig, } from './shared.js'; import { isPathSpec } from './plugin-spec.js'; @@ -111,15 +112,23 @@ function readPluginConfigLayers(workingDirectory) { const customPath = getActiveCustomConfigPath(); const userPath = getPrimaryUserConfigPath(); const projectPath = getProjectConfigPath(workingDirectory); + const userLayer = readConfigLayer(userPath); + const projectLayer = readConfigLayer(projectPath); + const customLayer = readConfigLayer(customPath); return { - userConfig: readConfigFile(userPath), - projectConfig: readConfigFile(projectPath), - customConfig: readConfigFile(customPath), + userConfig: userLayer.config, + projectConfig: projectLayer.config, + customConfig: customLayer.config, paths: { userPath, projectPath, customPath, }, + layerErrors: [ + userLayer.error && { path: userPath, code: userLayer.error.code, message: userLayer.error.message }, + projectLayer.error && projectPath && { path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message }, + customLayer.error && customPath && { path: customPath, code: customLayer.error.code, message: customLayer.error.message }, + ].filter(Boolean), }; } diff --git a/packages/web/server/lib/opencode/plugins.test.js b/packages/web/server/lib/opencode/plugins.test.js index 1e624445..974b788a 100644 --- a/packages/web/server/lib/opencode/plugins.test.js +++ b/packages/web/server/lib/opencode/plugins.test.js @@ -121,6 +121,24 @@ describe('opencode plugins data layer', () => { expect(readJson(userConfigPath)).toEqual({}); }); + test('lists user plugins when a project layer is unparseable', () => { + const partialProject = [ + '{', + ' "$schema": "https://opencode.ai/config.json",', + ' plugin: ["broken-project-plugin"],', + '}', + '', + ].join('\n'); + writeJson(userConfigPath, { plugin: ['user-plugin'] }); + const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc'); + fs.mkdirSync(path.dirname(projectFile), { recursive: true }); + fs.writeFileSync(projectFile, partialProject, 'utf8'); + + expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['user-plugin']); + expect(fs.readFileSync(projectFile, 'utf8')).toBe(partialProject); + expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false); + }); + test('lists entries from user and project layers with scopes and parsed kinds', () => { writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] }); writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] }); diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 87a105d4..bc90ff31 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -644,6 +644,7 @@ export { parseMdFile, writeMdFile, readConfigFile, + readConfigLayer, isPlainObject, readConfigLayers, readConfig,