diff --git a/CHANGELOG.md b/CHANGELOG.md index a980e698..ddc38b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 8132a578..18c11df8 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -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. diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 3a4d3dde..e7d6e610 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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. diff --git a/packages/vscode/src/opencodeConfig.config-parse.test.ts b/packages/vscode/src/opencodeConfig.config-parse.test.ts new file mode 100644 index 00000000..bd6f08b2 --- /dev/null +++ b/packages/vscode/src/opencodeConfig.config-parse.test.ts @@ -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); + }); +}); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index a14ef031..199f348e 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -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 => { + 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; +}; + const readConfigFile = (filePath?: string | null): Record => { 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; + // 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 => @@ -695,6 +716,11 @@ const getConfigForPath = (layers: ReturnType, targetPat const writeConfig = (config: Record, 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); diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 3b6c453c..cbe9f6c8 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 062d3d0f..f99360d8 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -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'); } diff --git a/packages/web/server/lib/opencode/shared.test.js b/packages/web/server/lib/opencode/shared.test.js index e131aa0c..a3e646b7 100644 --- a/packages/web/server/lib/opencode/shared.test.js +++ b/packages/web/server/lib/opencode/shared.test.js @@ -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; + } + }); +});