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:
co-authored by
serkraser
parent
6b1e677aaf
commit
4cc090130c
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user