fix(config): isolate plugin list reads from a broken JSONC layer
Plugin listing still called readConfigFile per layer, so one unparseable project file made GET /api/config/plugins and VS Code listPluginEntries fail. Reuse readConfigLayer isolation and pin comment-only empty parse in the VS Code suite. Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
co-authored by
serkraser
parent
8b086343fd
commit
6751c7dc7a
@@ -58,7 +58,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
|||||||
- `bridge-config-runtime.ts`
|
- `bridge-config-runtime.ts`
|
||||||
- Config and skills message handlers (`api:config/*`).
|
- Config and skills message handlers (`api:config/*`).
|
||||||
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
||||||
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, and writes still refuse to overwrite the broken file.
|
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file.
|
||||||
|
|
||||||
- `bridge-settings-runtime.ts`
|
- `bridge-settings-runtime.ts`
|
||||||
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { updateMcpConfig } from './opencodeConfig';
|
import { listPluginEntries, updateMcpConfig } from './opencodeConfig';
|
||||||
|
|
||||||
const PARTIAL_PARSE_CONFIG = [
|
const PARTIAL_PARSE_CONFIG = [
|
||||||
'{',
|
'{',
|
||||||
@@ -94,6 +94,29 @@ describe('opencodeConfig JSONC parse safety (issue #2923)', () => {
|
|||||||
assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG);
|
assert.equal(fs.readFileSync(`${configPath}.openchamber.backup`, 'utf8'), VALID_CONFIG);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns an empty object for a comment-only config file', () => {
|
||||||
|
const configPath = path.join(tempDir, 'comments.jsonc');
|
||||||
|
fs.writeFileSync(configPath, '// placeholder\n/* still empty */\n', 'utf8');
|
||||||
|
process.env.OPENCODE_CONFIG = configPath;
|
||||||
|
|
||||||
|
assert.deepEqual(listPluginEntries(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists custom-layer plugins when a project layer is unparseable', () => {
|
||||||
|
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||||
|
const projectDir = path.join(tempDir, 'project');
|
||||||
|
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||||
|
fs.writeFileSync(customPath, VALID_CONFIG, 'utf8');
|
||||||
|
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||||
|
fs.writeFileSync(projectFile, PARTIAL_PARSE_CONFIG, 'utf8');
|
||||||
|
process.env.OPENCODE_CONFIG = customPath;
|
||||||
|
|
||||||
|
const specs = listPluginEntries(projectDir).map((entry) => entry.spec);
|
||||||
|
assert.deepEqual(specs, ['opencode-see-image']);
|
||||||
|
assert.equal(fs.readFileSync(projectFile, 'utf8'), PARTIAL_PARSE_CONFIG);
|
||||||
|
assert.equal(fs.existsSync(`${projectFile}.openchamber.backup`), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps a valid custom layer writable when a project layer is unparseable', () => {
|
test('keeps a valid custom layer writable when a project layer is unparseable', () => {
|
||||||
const customPath = path.join(tempDir, 'custom.jsonc');
|
const customPath = path.join(tempDir, 'custom.jsonc');
|
||||||
const projectDir = path.join(tempDir, 'project');
|
const projectDir = path.join(tempDir, 'project');
|
||||||
|
|||||||
@@ -926,10 +926,10 @@ const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope
|
|||||||
const projectPath = getProjectConfigPath(workingDirectory || undefined);
|
const projectPath = getProjectConfigPath(workingDirectory || undefined);
|
||||||
return [
|
return [
|
||||||
customPath
|
customPath
|
||||||
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
|
? { scope: 'user', path: customPath, config: readConfigLayer(customPath).config }
|
||||||
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
|
: { scope: 'user', path: userPath, config: readConfigLayer(userPath).config },
|
||||||
...(projectPath
|
...(projectPath
|
||||||
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
|
? [{ scope: 'project' as const, path: projectPath, config: readConfigLayer(projectPath).config }]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
- `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.
|
- `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 (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).
|
||||||
|
- `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.
|
- `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.
|
- `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.
|
||||||
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer.
|
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates. Throws `INVALID_JSONC` when the chosen target file is the unparseable layer.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
import {
|
import {
|
||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
readConfigFile,
|
readConfigFile,
|
||||||
|
readConfigLayer,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
import { isPathSpec } from './plugin-spec.js';
|
import { isPathSpec } from './plugin-spec.js';
|
||||||
@@ -111,15 +112,23 @@ function readPluginConfigLayers(workingDirectory) {
|
|||||||
const customPath = getActiveCustomConfigPath();
|
const customPath = getActiveCustomConfigPath();
|
||||||
const userPath = getPrimaryUserConfigPath();
|
const userPath = getPrimaryUserConfigPath();
|
||||||
const projectPath = getProjectConfigPath(workingDirectory);
|
const projectPath = getProjectConfigPath(workingDirectory);
|
||||||
|
const userLayer = readConfigLayer(userPath);
|
||||||
|
const projectLayer = readConfigLayer(projectPath);
|
||||||
|
const customLayer = readConfigLayer(customPath);
|
||||||
return {
|
return {
|
||||||
userConfig: readConfigFile(userPath),
|
userConfig: userLayer.config,
|
||||||
projectConfig: readConfigFile(projectPath),
|
projectConfig: projectLayer.config,
|
||||||
customConfig: readConfigFile(customPath),
|
customConfig: customLayer.config,
|
||||||
paths: {
|
paths: {
|
||||||
userPath,
|
userPath,
|
||||||
projectPath,
|
projectPath,
|
||||||
customPath,
|
customPath,
|
||||||
},
|
},
|
||||||
|
layerErrors: [
|
||||||
|
userLayer.error && { path: userPath, code: userLayer.error.code, message: userLayer.error.message },
|
||||||
|
projectLayer.error && projectPath && { path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message },
|
||||||
|
customLayer.error && customPath && { path: customPath, code: customLayer.error.code, message: customLayer.error.message },
|
||||||
|
].filter(Boolean),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ describe('opencode plugins data layer', () => {
|
|||||||
expect(readJson(userConfigPath)).toEqual({});
|
expect(readJson(userConfigPath)).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lists user plugins when a project layer is unparseable', () => {
|
||||||
|
const partialProject = [
|
||||||
|
'{',
|
||||||
|
' "$schema": "https://opencode.ai/config.json",',
|
||||||
|
' plugin: ["broken-project-plugin"],',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
writeJson(userConfigPath, { plugin: ['user-plugin'] });
|
||||||
|
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
||||||
|
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
||||||
|
fs.writeFileSync(projectFile, partialProject, 'utf8');
|
||||||
|
|
||||||
|
expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['user-plugin']);
|
||||||
|
expect(fs.readFileSync(projectFile, 'utf8')).toBe(partialProject);
|
||||||
|
expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('lists entries from user and project layers with scopes and parsed kinds', () => {
|
test('lists entries from user and project layers with scopes and parsed kinds', () => {
|
||||||
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
|
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
|
||||||
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
|
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ export {
|
|||||||
parseMdFile,
|
parseMdFile,
|
||||||
writeMdFile,
|
writeMdFile,
|
||||||
readConfigFile,
|
readConfigFile,
|
||||||
|
readConfigLayer,
|
||||||
isPlainObject,
|
isPlainObject,
|
||||||
readConfigLayers,
|
readConfigLayers,
|
||||||
readConfig,
|
readConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user