Files
openchamber/packages/web/server/lib/opencode/shared.js
T
Cursor Agentandserkraser 4cc090130c 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>
2026-08-15 04:25:40 +00:00

609 lines
17 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
// ============== PATH CONSTANTS ==============
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const SKILL_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
// ============== DIRECTORY OPERATIONS ==============
function ensureDirs() {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
}
if (!fs.existsSync(AGENT_DIR)) {
fs.mkdirSync(AGENT_DIR, { recursive: true });
}
if (!fs.existsSync(COMMAND_DIR)) {
fs.mkdirSync(COMMAND_DIR, { recursive: true });
}
if (!fs.existsSync(SKILL_DIR)) {
fs.mkdirSync(SKILL_DIR, { recursive: true });
}
}
// ============== MARKDOWN FILE OPERATIONS ==============
// Mirror of OpenCode's markdown frontmatter sanitizer (packages/opencode/src/
// config/markdown.ts): other coding agents accept unquoted colons in YAML
// values (e.g. `description: Build agent: creates builds`), which strict YAML
// rejects. Rewrite those values as block scalars and retry the parse, so files
// OpenCode accepts are parsed identically here.
function sanitizeFrontmatter(frontmatter) {
return frontmatter
.split(/\r?\n/)
.flatMap((line) => {
if (line.trim().startsWith('#') || line.trim() === '' || /^\s+/.test(line)) return [line];
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
if (!entry) return [line];
const value = entry[2].trim();
if (value === '' || value === '>' || value === '|' || value.startsWith('"') || value.startsWith("'")) return [line];
if (!value.includes(':')) return [line];
return [`${entry[1]}: |-`, ` ${value}`];
})
.join('\n');
}
function parseMdFile(filePath) {
const rawContent = fs.readFileSync(filePath, 'utf8');
// Strip a UTF-8 BOM so frontmatter is recognized regardless of the editor
// that saved the file.
const content = rawContent.charCodeAt(0) === 0xfeff ? rawContent.slice(1) : rawContent;
// The closing `---` may sit at end-of-file without a trailing newline.
// gray-matter (used by OpenCode) accepts that, so we must too: otherwise the
// whole file is treated as the prompt body and a later save rewrites the
// existing YAML block into the body, duplicating the frontmatter.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
}
let frontmatter = {};
try {
frontmatter = yaml.parse(match[1]) || {};
} catch (error) {
// Lenient fallback for frontmatter that strict YAML rejects but OpenCode
// still accepts (unquoted colons in scalar values).
try {
frontmatter = yaml.parse(sanitizeFrontmatter(match[1])) || {};
} catch {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
}
}
const body = match[2].trim();
return { frontmatter, body };
}
function writeMdFile(filePath, frontmatter, body) {
try {
const cleanedFrontmatter = Object.fromEntries(
Object.entries(frontmatter).filter(([, value]) => value != null)
);
const yamlStr = yaml.stringify(cleanedFrontmatter);
const content = `---\n${yamlStr}---\n\n${body}`;
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Successfully wrote markdown file: ${filePath}`);
} catch (error) {
console.error(`Failed to write markdown file ${filePath}:`, error);
throw new Error('Failed to write agent markdown file');
}
}
// ============== CONFIG FILE OPERATIONS ==============
function getProjectConfigCandidates(workingDirectory) {
if (!workingDirectory) return [];
return [
path.join(workingDirectory, 'opencode.json'),
path.join(workingDirectory, 'opencode.jsonc'),
path.join(workingDirectory, '.opencode', 'opencode.json'),
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
];
}
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
const candidates = getProjectConfigCandidates(workingDirectory);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
function getConfigPaths(workingDirectory) {
return {
userPaths: [
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}
function getPrimaryUserConfigPath(userPaths) {
for (const userPath of userPaths) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
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 {};
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const normalized = content.trim();
if (!normalized) {
return {};
}
// 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');
}
}
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function mergeConfigs(base, override) {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (key in result) {
const baseValue = result[key];
if (isPlainObject(baseValue) && isPlainObject(value)) {
result[key] = mergeConfigs(baseValue, value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
function readConfigLayers(workingDirectory) {
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
const userPath = getPrimaryUserConfigPath(userPaths);
const userConfig = readConfigFile(userPath);
const projectConfig = readConfigFile(projectPath);
const customConfig = readConfigFile(customPath);
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
return {
userConfig,
projectConfig,
customConfig,
mergedConfig,
paths: { userPath, projectPath, customPath }
};
}
function readConfig(workingDirectory) {
return readConfigLayers(workingDirectory).mergedConfig;
}
function getConfigForPath(layers, targetPath) {
if (!targetPath) {
return layers.userConfig;
}
if (layers.paths.customPath && targetPath === layers.paths.customPath) {
return layers.customConfig;
}
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
return layers.projectConfig;
}
return layers.userConfig;
}
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}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
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');
}
}
function getJsonEntrySource(layers, sectionKey, entryName) {
const { userConfig, projectConfig, customConfig, paths } = layers;
const customSection = customConfig?.[sectionKey]?.[entryName];
if (customSection !== undefined) {
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
}
const projectSection = projectConfig?.[sectionKey]?.[entryName];
if (projectSection !== undefined) {
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
}
const userSection = userConfig?.[sectionKey]?.[entryName];
if (userSection !== undefined) {
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
}
function getJsonWriteTarget(layers, preferredScope) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath };
}
// ============== GIT/WORKTREE HELPERS ==============
function getAncestors(startDir, stopDir) {
if (!startDir) return [];
const result = [];
let current = path.resolve(startDir);
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
while (true) {
result.push(current);
if (resolvedStop && current === resolvedStop) {
break;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return result;
}
function findWorktreeRoot(startDir) {
if (!startDir) return null;
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
// ============== PROMPT FILE HELPERS ==============
function isPromptFileReference(value) {
if (typeof value !== 'string') {
return false;
}
return PROMPT_FILE_PATTERN.test(value.trim());
}
function resolvePromptFilePath(reference) {
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
if (!match) {
return null;
}
let target = match[1].trim();
if (!target) {
return null;
}
if (target.startsWith('./')) {
target = target.slice(2);
target = path.join(OPENCODE_CONFIG_DIR, target);
} else if (!path.isAbsolute(target)) {
target = path.join(OPENCODE_CONFIG_DIR, target);
}
return target;
}
function writePromptFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content ?? '', 'utf8');
console.log(`Updated prompt file: ${filePath}`);
}
// ============== SKILL FILE OPERATIONS ==============
function walkSkillMdFiles(rootDir) {
if (!rootDir || !fs.existsSync(rootDir)) return [];
const results = [];
const walk = (dir) => {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'SKILL.md') {
results.push(fullPath);
}
}
};
walk(rootDir);
return results;
}
function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) {
let parsed;
try {
parsed = parseMdFile(skillMdPath);
} catch {
return;
}
const name = typeof parsed.frontmatter?.name === 'string'
? parsed.frontmatter.name.trim()
: '';
const description = typeof parsed.frontmatter?.description === 'string'
? parsed.frontmatter.description
: '';
if (!name) {
return;
}
skillsMap.set(name, {
name,
path: skillMdPath,
scope,
source,
description,
});
}
function resolveSkillSearchDirectories(workingDirectory) {
const directories = [];
const pushDir = (dir) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!directories.includes(resolved)) {
directories.push(resolved);
}
};
pushDir(OPENCODE_CONFIG_DIR);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
.map((dir) => path.join(dir, '.opencode'));
projectDirs.forEach(pushDir);
}
pushDir(path.join(os.homedir(), '.opencode'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
pushDir(customConfigDir);
return directories;
}
function listSkillSupportingFiles(skillDir) {
if (!fs.existsSync(skillDir)) {
return [];
}
const files = [];
function walkDir(dir, relativePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
walkDir(fullPath, relPath);
} else if (entry.name !== 'SKILL.md') {
files.push({
name: entry.name,
path: relPath,
fullPath: fullPath
});
}
}
}
walkDir(skillDir);
return files;
}
function assertPathWithinSkillDir(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const target = path.resolve(root, relativePath);
const relative = path.relative(root, target);
const isWithin = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
if (!isWithin) {
const error = new Error('Access to file denied');
error.code = 'EACCES';
throw error;
}
return target;
}
function readSkillSupportingFile(skillDir, relativePath) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
return fs.readFileSync(fullPath, 'utf8');
}
function writeSkillSupportingFile(skillDir, relativePath, content) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function deleteSkillSupportingFile(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
let parentDir = path.dirname(fullPath);
while (parentDir !== root) {
try {
const entries = fs.readdirSync(parentDir);
if (entries.length === 0) {
fs.rmdirSync(parentDir);
parentDir = path.dirname(parentDir);
} else {
break;
}
} catch {
break;
}
}
}
}
export {
OPENCODE_CONFIG_DIR,
AGENT_DIR,
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
ensureDirs,
parseMdFile,
writeMdFile,
readConfigFile,
isPlainObject,
readConfigLayers,
readConfig,
getConfigForPath,
writeConfig,
getJsonEntrySource,
getJsonWriteTarget,
getAncestors,
findWorktreeRoot,
isPromptFileReference,
resolvePromptFilePath,
writePromptFile,
walkSkillMdFiles,
addSkillFromMdFile,
resolveSkillSearchDirectories,
listSkillSupportingFiles,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
};