fix(config): refuse partial JSONC parses that wipe opencode.jsonc

jsonc-parser was returning truncated trees (often only `$schema`) when
configs contained JSON5-style unquoted keys. Config mutations then backed
up and overwrote the full file with that stub. Check parse errors on read
and refuse to overwrite unparseable files on write in web and VS Code.

Fixes #2923

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-15 04:25:40 +00:00
co-authored by serkraser
parent 6b1e677aaf
commit 4cc090130c
8 changed files with 312 additions and 6 deletions
@@ -71,7 +71,8 @@ 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).
- `writeConfig(config, filePath)`: Writes config with automatic backup.
- `readConfigFile(filePath)`: Reads one config file. Empty/missing files return `{}`. Any `jsonc-parser` error or non-object result throws `INVALID_JSONC` — partial parse trees must never be treated as authoritative (avoids rewriting a `$schema`-only stub over a full config).
- `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.
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates.
- `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers.
+41 -2
View File
@@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import { parse as parseJsonc } from 'jsonc-parser';
import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
// ============== PATH CONSTANTS ==============
@@ -168,6 +168,31 @@ function getPrimaryUserConfigPath(userPaths) {
return CONFIG_FILE;
}
const INVALID_JSONC = 'INVALID_JSONC';
function isInvalidJsoncError(error) {
return Boolean(error && typeof error === 'object' && error.code === INVALID_JSONC);
}
function formatJsoncParseError(filePath, errors) {
const first = Array.isArray(errors) && errors.length > 0 ? errors[0] : null;
const location = first && Number.isFinite(first.offset)
? ` (${printParseErrorCode(first.error)} at offset ${first.offset})`
: '';
return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`;
}
function parseConfigObject(content, filePath) {
const errors = [];
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error = new Error(formatJsoncParseError(filePath, errors));
error.code = INVALID_JSONC;
throw error;
}
return parsed;
}
function readConfigFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return {};
@@ -178,8 +203,13 @@ function readConfigFile(filePath) {
if (!normalized) {
return {};
}
return parseJsonc(normalized, [], { allowTrailingComma: true });
// Refuse partial jsonc-parser trees. Ignoring errors previously let mutations
// rewrite a truncated object (often only `$schema`) over the full config.
return parseConfigObject(normalized, filePath);
} catch (error) {
if (isInvalidJsoncError(error)) {
throw error;
}
console.error(`Failed to read config file: ${filePath}`, error);
throw new Error('Failed to read OpenCode configuration');
}
@@ -246,6 +276,12 @@ function getConfigForPath(layers, targetPath) {
function writeConfig(config, filePath = CONFIG_FILE) {
try {
if (fs.existsSync(filePath)) {
// Defense in depth: never overwrite a file we cannot fully parse.
const existing = fs.readFileSync(filePath, 'utf8').trim();
if (existing) {
parseConfigObject(existing, filePath);
}
const backupFile = `${filePath}.openchamber.backup`;
fs.copyFileSync(filePath, backupFile);
console.log(`Created config backup: ${backupFile}`);
@@ -255,6 +291,9 @@ function writeConfig(config, filePath = CONFIG_FILE) {
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
console.log(`Successfully wrote config file: ${filePath}`);
} catch (error) {
if (isInvalidJsoncError(error)) {
throw error;
}
console.error(`Failed to write config file: ${filePath}`, error);
throw new Error('Failed to write OpenCode configuration');
}
+139 -1
View File
@@ -3,8 +3,9 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { parseMdFile, writeMdFile } from './shared.js';
import { parseMdFile, writeMdFile, readConfigFile, writeConfig } from './shared.js';
import { updateAgent } from './agents.js';
import { updateMcpConfig } from './mcp.js';
const FIXTURE_DIR = path.join(os.tmpdir(), `openchamber-shared-test-${process.pid}`);
@@ -200,3 +201,140 @@ describe('updateAgent frontmatter preservation', () => {
expect(parsed.body).toBe('Body of strateg.');
});
});
describe('readConfigFile / writeConfig JSONC safety (issue #2923)', () => {
beforeEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
});
const VALID_CONFIG = [
'{',
' "$schema": "https://opencode.ai/config.json",',
' // keep me',
' "plugin": ["opencode-see-image"],',
' "mcp": {',
' "openproject": {',
' "type": "remote",',
' "url": "https://openproject.example.com/mcp",',
' "enabled": true,',
' }',
' },',
' "provider": {',
' "ollama-cloud": {',
' "npm": "@ai-sdk/openai-compatible",',
' "name": "Ollama Cloud"',
' }',
' }',
'}',
'',
].join('\n');
// JSON5-style unquoted keys after $schema — jsonc-parser returns a partial
// tree of only `{ $schema }` when errors are ignored.
const PARTIAL_PARSE_CONFIG = [
'{',
' "$schema": "https://opencode.ai/config.json",',
' plugin: ["opencode-see-image"],',
' mcp: {',
' openproject: {',
' type: "remote",',
' url: "https://openproject.example.com/mcp",',
' enabled: true',
' }',
' },',
' provider: {',
' "ollama-cloud": {',
' npm: "@ai-sdk/openai-compatible",',
' name: "Ollama Cloud"',
' }',
' }',
'}',
'',
].join('\n');
it('parses valid JSONC with comments and trailing commas without dropping keys', () => {
const file = writeFixture('opencode.jsonc', VALID_CONFIG);
expect(readConfigFile(file)).toEqual({
$schema: 'https://opencode.ai/config.json',
plugin: ['opencode-see-image'],
mcp: {
openproject: {
type: 'remote',
url: 'https://openproject.example.com/mcp',
enabled: true,
},
},
provider: {
'ollama-cloud': {
npm: '@ai-sdk/openai-compatible',
name: 'Ollama Cloud',
},
},
});
});
it('returns an empty object for a missing or whitespace-only file', () => {
expect(readConfigFile(path.join(FIXTURE_DIR, 'missing.jsonc'))).toEqual({});
const empty = writeFixture('empty.jsonc', ' \n');
expect(readConfigFile(empty)).toEqual({});
});
it('throws INVALID_JSONC on partial-parse JSONC instead of returning a $schema-only stub', () => {
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/);
try {
readConfigFile(file);
} catch (error) {
expect(error.code).toBe('INVALID_JSONC');
}
});
it('throws INVALID_JSONC for a non-object JSONC root', () => {
const file = writeFixture('array.jsonc', '["plugin"]\n');
expect(() => readConfigFile(file)).toThrow(/cannot be loaded safely/);
});
it('refuses to overwrite an unparseable config file', () => {
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, file)).toThrow(
/cannot be loaded safely/,
);
expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
});
it('preserves a valid config across MCP updates', () => {
const file = writeFixture('opencode.jsonc', VALID_CONFIG);
const config = readConfigFile(file);
config.mcp.openproject.enabled = false;
writeConfig(config, file);
const rewritten = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(rewritten.plugin).toEqual(['opencode-see-image']);
expect(rewritten.provider['ollama-cloud'].name).toBe('Ollama Cloud');
expect(rewritten.mcp.openproject.enabled).toBe(false);
expect(fs.readFileSync(`${file}.openchamber.backup`, 'utf8')).toBe(VALID_CONFIG);
});
it('does not wipe an unparseable user config during MCP mutation attempts', () => {
const file = writeFixture('opencode.jsonc', PARTIAL_PARSE_CONFIG);
const previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
try {
process.env.OPENCODE_CONFIG = file;
expect(() => updateMcpConfig('openproject', { enabled: true })).toThrow(
/cannot be loaded safely/,
);
expect(fs.readFileSync(file, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
expect(fs.existsSync(`${file}.openchamber.backup`)).toBe(false);
} finally {
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
}
});
});