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
+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
- **Usage/Claude:** Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes.
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
+4
View File
@@ -1,3 +1,7 @@
## [Unreleased]
- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
## [1.18.4] - 2026-08-14
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
+1
View File
@@ -58,6 +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 any `jsonc-parser` error or non-object result (`INVALID_JSONC`) so mutations cannot rewrite a partial `$schema`-only stub over an existing config.
- `bridge-settings-runtime.ts`
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { updateMcpConfig } from './opencodeConfig';
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');
const VALID_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');
describe('opencodeConfig JSONC parse safety (issue #2923)', () => {
let tempDir: string;
let previousOpenCodeConfig: string | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-config-parse-'));
previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
});
afterEach(() => {
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
fs.rmSync(tempDir, { recursive: true, force: true });
});
test('refuses MCP updates that would overwrite a partial-parse config', () => {
const configPath = path.join(tempDir, 'opencode.jsonc');
fs.writeFileSync(configPath, PARTIAL_PARSE_CONFIG, 'utf8');
process.env.OPENCODE_CONFIG = configPath;
assert.throws(
() => updateMcpConfig('openproject', { enabled: true }),
(error: unknown) => (
error instanceof Error
&& /cannot be loaded safely/.test(error.message)
&& (error as Error & { code?: string }).code === 'INVALID_JSONC'
),
);
assert.equal(fs.readFileSync(configPath, 'utf8'), PARTIAL_PARSE_CONFIG);
assert.equal(fs.existsSync(`${configPath}.openchamber.backup`), false);
});
test('preserves unrelated keys when updating a valid MCP config', () => {
const configPath = path.join(tempDir, 'opencode.jsonc');
fs.writeFileSync(configPath, VALID_CONFIG, 'utf8');
process.env.OPENCODE_CONFIG = configPath;
updateMcpConfig('openproject', { enabled: false });
const rewritten = JSON.parse(fs.readFileSync(configPath, 'utf8'));
assert.deepEqual(rewritten.plugin, ['opencode-see-image']);
assert.equal(rewritten.provider['ollama-cloud'].name, 'Ollama Cloud');
assert.equal(rewritten.mcp.openproject.enabled, false);
assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG);
});
});
+28 -2
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import yaml from 'yaml';
import { parse as parseJsonc } from 'jsonc-parser';
import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
@@ -554,12 +554,33 @@ const getPrimaryUserConfigPath = (userPaths: string[]): string => {
return CONFIG_FILE;
};
const INVALID_JSONC = 'INVALID_JSONC';
const formatJsoncParseError = (filePath: string, errors: ParseError[]): string => {
const first = 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}`;
};
const parseConfigObject = (content: string, filePath: string): Record<string, unknown> => {
const errors: ParseError[] = [];
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw codedError(formatJsoncParseError(filePath, errors), INVALID_JSONC);
}
return parsed as Record<string, unknown>;
};
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
if (!filePath || !fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, 'utf8');
const normalized = content.trim();
if (!normalized) return {};
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
// 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);
};
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -695,6 +716,11 @@ const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPat
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
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`;
try {
fs.copyFileSync(filePath, backupFile);
@@ -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;
}
});
});