fix(config): fail closed when config content yields no JSON value

Treating every undefined parse as empty config let a file that is not JSON
at all (YAML, plain text) read as {}, so a later write would back it up and
replace it - the same data loss this fix is meant to prevent. Only a
comment-only parse, where ValueExpected is the sole error, counts as empty.
This commit is contained in:
Serhii Dziupin
2026-08-17 16:44:17 +03:00
parent 07f8264e72
commit 6d6ece6856
6 changed files with 49 additions and 16 deletions
@@ -71,7 +71,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `ensureDirs()`: Creates required OpenCode directories.
- `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).
- `readConfigFile(filePath)`: Reads one config file. Missing, whitespace-only, and comment-only files return `{}`; a comment-only file is recognized by `ValueExpected` being the only parse error. 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). Content that yields no JSON value for any other reason (YAML, plain text) also throws instead of reading as empty.
- `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.
+9 -3
View File
@@ -182,12 +182,18 @@ function formatJsoncParseError(filePath, errors) {
return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`;
}
function isCommentOnlyParse(parsed, errors) {
// Comment-only / whitespace-only files parse to undefined with nothing but
// ValueExpected. Any other error means real content we failed to understand
// (YAML, plain text, a stray leading token), which must not read as empty.
return parsed === undefined
&& errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected');
}
function parseConfigObject(content, filePath) {
const errors = [];
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
// Comment-only / no JSON value: jsonc-parser returns undefined plus ValueExpected.
// That is empty config, not a partial tree. The data-loss bug is errors + object.
if (parsed === undefined) {
if (isCommentOnlyParse(parsed, errors)) {
return {};
}
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -343,6 +343,16 @@ describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => {
expect(readConfigFile(file)).toEqual({});
});
it('throws INVALID_JSONC for content that yields no JSON value at all', () => {
const yamlish = writeFixture('yamlish.jsonc', 'mcp:\n openproject:\n type: remote\n');
expect(() => readConfigFile(yamlish)).toThrow(/cannot be loaded safely/);
expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, yamlish)).toThrow(
/cannot be loaded safely/,
);
expect(fs.readFileSync(yamlish, 'utf8')).toBe('mcp:\n openproject:\n type: remote\n');
expect(fs.existsSync(`${yamlish}.openchamber.backup`)).toBe(false);
});
it('keeps a valid custom layer readable when a project layer is unparseable', () => {
const custom = writeFixture('custom.jsonc', VALID_CONFIG);
const projectDir = path.join(FIXTURE_DIR, 'project');