Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs

# Conflicts:
#	packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx
#	packages/ui/src/lib/i18n/messages/de.ts
#	packages/ui/src/lib/i18n/messages/en.ts
#	packages/ui/src/lib/i18n/messages/es.ts
#	packages/ui/src/lib/i18n/messages/fr.ts
#	packages/ui/src/lib/i18n/messages/ja.ts
#	packages/ui/src/lib/i18n/messages/ko.ts
#	packages/ui/src/lib/i18n/messages/pl.ts
#	packages/ui/src/lib/i18n/messages/pt-BR.ts
#	packages/ui/src/lib/i18n/messages/uk.ts
#	packages/ui/src/lib/i18n/messages/zh-CN.ts
#	packages/ui/src/lib/i18n/messages/zh-TW.ts
#	packages/web/server/lib/opencode/settings-helpers.js
This commit is contained in:
2026-08-18 20:22:15 +00:00
187 changed files with 14970 additions and 2196 deletions
@@ -70,10 +70,12 @@ This module provides OpenCode server integration utilities for the web server ru
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `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.
- `getJsonEntrySource(layers, sectionKey, entryName)`: Resolves which config layer provides an entry.
- `getJsonWriteTarget(layers, preferredScope)`: Determines write target for config updates.
- `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 return `{}`; a comment-only file is recognized by `ValueExpected` being the only parse error. 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). Content that yields no JSON value for any other reason (YAML, plain text) also throws instead of reading as empty.
- `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.
- `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.
- `getAncestors(startDir, stopDir)`, `findWorktreeRoot(startDir)`: Git worktree helpers.
- `isPromptFileReference(value)`, `resolvePromptFilePath(reference)`, `writePromptFile(filePath, content)`: Prompt file reference handling.
- `walkSkillMdFiles(rootDir)`: Recursively finds all SKILL.md files.
@@ -11,6 +11,9 @@ import { registerGitProviderRoutes } from '../git-providers/routes.js';
import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
import { registerProjectContextRoutes } from '../project-context/routes.js';
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
import { registerConfigEntityRoutes } from './config-entity-routes.js';
import { registerSettingsUtilityRoutes } from './core-routes.js';
@@ -119,6 +122,10 @@ export const createFeatureRoutesRuntime = (dependencies) => {
devServerScanner,
buildAugmentedPath,
projectConfigRuntime,
projectContextRuntime,
agentMemoryRuntime,
isAgentMemoryEnabled,
sessionKnowledgeRuntime,
scheduledTasksRuntime,
scheduledTaskService,
openChamberSessionService,
@@ -310,6 +317,10 @@ export const createFeatureRoutesRuntime = (dependencies) => {
path,
openchamberDataDir,
});
registerProjectContextRoutes(app, { projectContextRuntime });
registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
registerSessionFoldersRoutes(app, {
fsPromises,
path,
+12 -3
View File
@@ -4,6 +4,7 @@ import path from 'path';
import {
AGENT_SCOPE,
readConfigFile,
readConfigLayer,
writeConfig,
} from './shared.js';
import { isPathSpec } from './plugin-spec.js';
@@ -111,15 +112,23 @@ function readPluginConfigLayers(workingDirectory) {
const customPath = getActiveCustomConfigPath();
const userPath = getPrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory);
const userLayer = readConfigLayer(userPath);
const projectLayer = readConfigLayer(projectPath);
const customLayer = readConfigLayer(customPath);
return {
userConfig: readConfigFile(userPath),
projectConfig: readConfigFile(projectPath),
customConfig: readConfigFile(customPath),
userConfig: userLayer.config,
projectConfig: projectLayer.config,
customConfig: customLayer.config,
paths: {
userPath,
projectPath,
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({});
});
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', () => {
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
@@ -0,0 +1,152 @@
import http from 'node:http';
import https from 'node:https';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { createProxyMiddlewareMock } = vi.hoisted(() => ({
createProxyMiddlewareMock: vi.fn(),
}));
vi.mock('http-proxy-middleware', () => ({
createProxyMiddleware: createProxyMiddlewareMock,
}));
const { registerOpenCodeProxy } = await import('./proxy.js');
const createStubApp = () => {
const settings = new Map();
const noop = () => {};
return {
get: (...args) => (args.length === 1 ? settings.get(args[0]) : undefined),
set: (key, value) => {
settings.set(key, value);
},
use: noop,
post: noop,
put: noop,
patch: noop,
delete: noop,
all: noop,
};
};
/**
* `state` is intentionally mutable so a test can model the production ordering:
* the proxy is registered before OpenCode bootstraps, so the port/base URL only
* become resolvable afterwards.
*/
const createStubDeps = (state) => ({
fs: { promises: { realpath: async (value) => value } },
os: {},
path: {},
OPEN_CODE_READY_GRACE_MS: 0,
LONG_REQUEST_TIMEOUT_MS: 1_000,
getRuntime: () => ({ openCodePort: state.port, openCodeBaseUrl: state.baseUrl }),
getOpenCodeAuthHeaders: () => ({}),
// Mirrors network-runtime.js: throws until the port is known.
buildOpenCodeUrl: (pathname) => {
if (!state.port) {
throw new Error('OpenCode port is not available');
}
return `${state.baseUrl}${pathname}`;
},
ensureOpenCodeApiPrefix: (pathname) => pathname,
});
const managedState = () => ({ port: 49303, baseUrl: 'http://127.0.0.1:49303' });
const coldState = () => ({ port: null, baseUrl: null });
const agentsFromCalls = () => createProxyMiddlewareMock.mock.calls.map(([options]) => options.agent);
describe('OpenCode API proxy agent wiring', () => {
beforeEach(() => {
createProxyMiddlewareMock.mockReset();
createProxyMiddlewareMock.mockImplementation(() => (_req, _res, next) => next?.());
});
it('constructs every proxy with a keep-alive agent', () => {
registerOpenCodeProxy(createStubApp(), createStubDeps(managedState()));
expect(createProxyMiddlewareMock).toHaveBeenCalled();
for (const agent of agentsFromCalls()) {
// Without an explicit agent, http-proxy falls back to `agent: false`,
// which forces `Connection: close` and burns one ephemeral port per
// request. See createOpenCodeProxyAgent in ./proxy.js.
expect(agent).toBeTruthy();
expect(agent.options?.keepAlive).toBe(true);
}
});
it('shares one agent instance across the API and OAuth proxies', () => {
registerOpenCodeProxy(createStubApp(), createStubDeps(managedState()));
const agents = agentsFromCalls();
expect(agents.length).toBeGreaterThan(1);
expect(agents.every(Boolean)).toBe(true);
expect(new Set(agents).size).toBe(1);
});
it('memoizes the agent per scheme rather than allocating one per resolution', () => {
registerOpenCodeProxy(createStubApp(), createStubDeps(managedState()));
const [options] = createProxyMiddlewareMock.mock.calls[0];
expect(options.agent).toBe(options.agent);
});
// Production ordering: startup-pipeline-runtime.js calls setupProxy() before
// bootstrapOpenCodeAtStartup(), so at registration the port is null,
// buildOpenCodeUrl throws, and resolveProxyTarget() falls back to the http
// loopback default. An external https server configured via OPENCODE_HOST is
// only visible after bootstrap, so the agent must be resolved lazily.
it('resolves an https agent after bootstrap even though registration ran cold', () => {
const state = coldState();
registerOpenCodeProxy(createStubApp(), createStubDeps(state));
// Cold: nothing resolvable yet, so the http fallback target applies.
for (const agent of agentsFromCalls()) {
expect(agent).not.toBeInstanceOf(https.Agent);
}
// Bootstrap completes against an external https server.
state.baseUrl = 'https://opencode.example.com:4096';
for (const agent of agentsFromCalls()) {
expect(agent).toBeInstanceOf(https.Agent);
// Asserted on the live resolver path, not just the exported factory:
// the https branch is the one a mutation could silently strip.
expect(agent.options?.keepAlive).toBe(true);
expect(agent.options?.maxFreeSockets).toBe(256);
}
});
it('keeps a plain http agent when bootstrap resolves an http target', () => {
const state = coldState();
registerOpenCodeProxy(createStubApp(), createStubDeps(state));
Object.assign(state, managedState());
for (const agent of agentsFromCalls()) {
// https.Agent extends http.Agent, so the negative assertion is load-bearing.
expect(agent).toBeInstanceOf(http.Agent);
expect(agent).not.toBeInstanceOf(https.Agent);
}
});
it('derives an https agent when the target is already https at registration', () => {
registerOpenCodeProxy(
createStubApp(),
createStubDeps({ port: 4096, baseUrl: 'https://opencode.example.com:4096' }),
);
const agents = agentsFromCalls();
expect(agents.length).toBeGreaterThan(0);
for (const agent of agents) {
expect(agent).toBeInstanceOf(https.Agent);
}
});
});
+116 -6
View File
@@ -1,3 +1,6 @@
import http from 'node:http';
import https from 'node:https';
import { createProxyMiddleware } from 'http-proxy-middleware';
import {
@@ -11,6 +14,96 @@ import { recordStartupPerformance } from './startup-performance.js';
const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
const OPENCODE_AGENT_KEEP_ALIVE_MS = 30_000;
// Node's own default. A lower cap evicts pooled sockets under concurrency,
// which reintroduces exactly the per-request connection churn this agent
// exists to prevent (measured: at 64 concurrent requests, a cap of 32 left
// 303 sockets in TIME_WAIT versus 0 at 256).
const OPENCODE_AGENT_MAX_FREE_SOCKETS = 256;
// Evicts idle free sockets from our side. Without it the only thing that
// retires an idle pooled socket is the upstream closing it. Note this is
// distinct from `keepAliveMsecs`, which is the TCP keep-alive probe delay.
const OPENCODE_AGENT_IDLE_TIMEOUT_MS = 60_000;
const OPENCODE_AGENT_OPTIONS = {
keepAlive: true,
keepAliveMsecs: OPENCODE_AGENT_KEEP_ALIVE_MS,
maxSockets: Infinity,
maxFreeSockets: OPENCODE_AGENT_MAX_FREE_SOCKETS,
timeout: OPENCODE_AGENT_IDLE_TIMEOUT_MS,
};
const isHttpsProxyTarget = (target) => {
if (typeof target !== 'string') {
return false;
}
try {
return new URL(target).protocol === 'https:';
} catch {
return /^https:/i.test(target.trim());
}
};
/**
* Agent for proxied OpenCode API requests.
*
* When no agent is supplied, `http-proxy` falls back to `agent: false`, which
* both disables connection pooling and forces `Connection: close` on every
* proxied request (http-proxy/lib/http-proxy/common.js). That consumes one
* ephemeral port per request, and sustained traffic can exhaust the host's
* ephemeral port range — after which every process on the machine fails to
* open outbound connections with EADDRNOTAVAIL.
*
* The agent must match the target scheme: http-proxy dispatches through
* `https.request` when `target.protocol === 'https:'`
* (http-proxy/lib/http-proxy/passes/web-incoming.js), and an `http.Agent`
* would open a plaintext socket to a TLS port. External servers may be
* configured over https via `OPENCODE_HOST` (see env-config.js), so derive the
* agent class from the resolved target.
*
* `maxSockets: Infinity` preserves the unbounded concurrency of `agent: false`,
* so this changes connection reuse only, not request throughput.
*/
export const createOpenCodeProxyAgent = (target) => (
isHttpsProxyTarget(target)
? new https.Agent(OPENCODE_AGENT_OPTIONS)
: new http.Agent(OPENCODE_AGENT_OPTIONS)
);
/**
* Lazily resolves the proxy agent, memoized per scheme.
*
* The scheme cannot be decided at registration time: `setupProxy()` runs before
* `bootstrapOpenCodeAtStartup()` (startup-pipeline-runtime.js), so on a cold
* start `state.openCodePort` is still null, `buildOpenCodeUrl()` throws
* (network-runtime.js) and `resolveProxyTarget()` falls back to the http
* loopback default. An external server configured over https via
* `OPENCODE_HOST` only becomes visible on `state.openCodeBaseUrl` after
* bootstrap completes.
*
* http-proxy-middleware rebuilds its per-request options with
* `Object.assign({}, this.proxyOptions)` inside `prepareProxyRequest`, which
* invokes getters, so exposing `agent` as a getter defers resolution to request
* time. Memoizing per scheme keeps a single shared pool per scheme rather than
* allocating an agent per request.
*/
const createOpenCodeProxyAgentResolver = (resolveTarget) => {
const agents = new Map();
return () => {
const target = resolveTarget();
const scheme = isHttpsProxyTarget(target) ? 'https:' : 'http:';
let agent = agents.get(scheme);
if (!agent) {
// Construct through the shared factory rather than inline, so both
// schemes are built from OPENCODE_AGENT_OPTIONS by the same code path.
agent = createOpenCodeProxyAgent(target);
agents.set(scheme, agent);
}
return agent;
};
};
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
@@ -285,15 +378,22 @@ export const registerOpenCodeProxy = (app, deps) => {
// and direct fetch helpers use. This avoids split-brain state where /health
// succeeds against an external host but /api/* still proxies to 127.0.0.1.
const resolveProxyTarget = () => {
try {
const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
if (resolved) {
return resolved;
const runtimeState = getRuntime();
// `buildOpenCodeUrl` throws while the port is unknown, and the port is
// nulled on several runtime paths (health-check failure, failed restart),
// not just cold start. Checking first keeps a degraded OpenCode from
// making every proxied request pay for a thrown-and-caught exception.
if (runtimeState.openCodePort) {
try {
const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
if (resolved) {
return resolved;
}
} catch {
}
} catch {
}
const runtimeState = getRuntime();
const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl);
if (externalBase) {
return externalBase;
@@ -767,8 +867,18 @@ export const registerOpenCodeProxy = (app, deps) => {
});
// Generic proxy for non-SSE OpenCode API routes.
// The agent is exposed as a getter so its class is resolved per request, not
// at registration: the proxy is registered before OpenCode bootstraps, so an
// https target configured via OPENCODE_HOST is not yet visible here. Agents
// are memoized per scheme, so this is still one shared pool per scheme across
// `apiProxy` and `interactiveOAuthProxy`.
const resolveOpenCodeProxyAgent = createOpenCodeProxyAgentResolver(resolveProxyTarget);
const createApiProxy = (timeoutMs) => createProxyMiddleware({
target: resolveProxyTarget(),
get agent() {
return resolveOpenCodeProxyAgent();
},
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: timeoutMs,
+158 -1
View File
@@ -1,6 +1,14 @@
import http from 'node:http';
import https from 'node:https';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { describe, expect, it } from 'vitest';
import { createDirectoryQueryCanonicalizer, normalizeForwardedDirectoryHeaders } from './proxy.js';
import {
createDirectoryQueryCanonicalizer,
createOpenCodeProxyAgent,
normalizeForwardedDirectoryHeaders,
} from './proxy.js';
describe('createDirectoryQueryCanonicalizer', () => {
it('canonicalizes directory query params and preserves other params', async () => {
@@ -93,3 +101,152 @@ describe('normalizeForwardedDirectoryHeaders', () => {
});
});
});
const listen = (server) => new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject);
resolve(server.address().port);
});
});
const closeServer = (server) => new Promise((resolve) => {
server.close(resolve);
});
const request = (port, agent) => new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: '/', method: 'GET', agent }, (res) => {
res.resume();
res.on('end', resolve);
res.on('error', reject);
});
req.on('error', reject);
req.end();
});
/**
* Proxies two sequential requests through `createProxyMiddleware` and reports
* what the upstream server observed for each one.
*/
const proxyTwoRequests = async (proxyAgent) => {
const seen = [];
let middleware;
const upstream = http.createServer((req, res) => {
seen.push({ connection: req.headers.connection, remotePort: req.socket.remotePort });
res.end('ok');
});
const front = http.createServer((req, res) => {
middleware(req, res, () => {
res.statusCode = 502;
res.end();
});
});
const clientAgent = new http.Agent({ keepAlive: true });
try {
const upstreamPort = await listen(upstream);
middleware = createProxyMiddleware({
target: `http://127.0.0.1:${upstreamPort}`,
...(proxyAgent ? { agent: proxyAgent } : {}),
});
const frontPort = await listen(front);
await request(frontPort, clientAgent);
await request(frontPort, clientAgent);
} finally {
clientAgent.destroy();
proxyAgent?.destroy();
await closeServer(front);
await closeServer(upstream);
}
return seen;
};
describe('createOpenCodeProxyAgent', () => {
it('reuses a single upstream socket across sequential proxied requests', async () => {
const seen = await proxyTwoRequests(createOpenCodeProxyAgent('http://127.0.0.1'));
expect(seen).toHaveLength(2);
expect(seen[0].connection).not.toBe('close');
expect(seen[1].remotePort).toBe(seen[0].remotePort);
});
it('without an agent, http-proxy forces Connection: close and a new socket per request', async () => {
const seen = await proxyTwoRequests(null);
expect(seen).toHaveLength(2);
expect(seen[0].connection).toBe('close');
expect(seen[1].remotePort).not.toBe(seen[0].remotePort);
});
// http-proxy dispatches through `https.request` when the target protocol is
// `https:`, so an http.Agent would open a plaintext socket to a TLS port.
// External OpenCode servers can be configured over https via OPENCODE_HOST.
it('returns an https agent for https targets', () => {
const agent = createOpenCodeProxyAgent('https://opencode.example.com:4096');
expect(agent).toBeInstanceOf(https.Agent);
expect(agent.options.keepAlive).toBe(true);
});
it('returns a plain http agent for http targets', () => {
const agent = createOpenCodeProxyAgent('http://127.0.0.1:4096');
// https.Agent extends http.Agent, so the negative assertion is the load-bearing one.
expect(agent).toBeInstanceOf(http.Agent);
expect(agent).not.toBeInstanceOf(https.Agent);
expect(agent.options.keepAlive).toBe(true);
});
it('falls back to an http agent for missing or unparseable targets', () => {
expect(createOpenCodeProxyAgent(undefined)).not.toBeInstanceOf(https.Agent);
expect(createOpenCodeProxyAgent('not a url')).not.toBeInstanceOf(https.Agent);
});
// The cold-start fix relies on http-proxy-middleware rebuilding its per-request
// options via `Object.assign({}, this.proxyOptions)` in prepareProxyRequest,
// which invokes getters. If that ever changes to a cached or shallow-reference
// copy, the agent would freeze at its registration-time value and https targets
// would silently regress — so pin the behavior here against the real library.
it('http-proxy-middleware re-reads the agent option on every proxied request', async () => {
let reads = 0;
let middleware;
const agent = createOpenCodeProxyAgent('http://127.0.0.1');
const upstream = http.createServer((_req, res) => res.end('ok'));
const front = http.createServer((req, res) => {
middleware(req, res, () => {
res.statusCode = 502;
res.end();
});
});
const clientAgent = new http.Agent({ keepAlive: true });
try {
const upstreamPort = await listen(upstream);
middleware = createProxyMiddleware({
target: `http://127.0.0.1:${upstreamPort}`,
get agent() {
reads += 1;
return agent;
},
});
// Construction itself must not read the getter — otherwise the assertion
// below could be satisfied without any per-request resolution happening.
expect(reads).toBe(0);
const frontPort = await listen(front);
await request(frontPort, clientAgent);
expect(reads).toBe(1);
await request(frontPort, clientAgent);
expect(reads).toBe(2);
} finally {
clientAgent.destroy();
agent.destroy();
await closeServer(front);
await closeServer(upstream);
}
});
});
@@ -1,4 +1,5 @@
import { sanitizeGitProviders } from '../git-providers/config.js';
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
export const createSettingsHelpers = (dependencies) => {
const {
@@ -517,6 +518,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
}
if (typeof candidate.optimizeSystemPrompt === 'boolean') {
result.optimizeSystemPrompt = candidate.optimizeSystemPrompt;
}
@@ -914,6 +918,9 @@ export const createSettingsHelpers = (dependencies) => {
return {
...sanitized,
hasManagedRemoteTunnelToken,
// Tells the client whether agent memory exists in this build at all, so
// its settings row and panel tab can be absent rather than merely off.
agentMemoryFeatureAvailable: isAgentMemoryFeatureAvailable(),
...(pwaAppName ? { pwaAppName } : {}),
pwaOrientation,
mobileKeyboardMode,
@@ -212,6 +212,56 @@ export const createSettingsRuntime = (deps) => {
}
};
/**
* Merge the server-owned `context.json` (notes/todos/plans) across a project
* id change.
*
* `moveDirectoryContents` only renames a file when the destination is free,
* so without this step an existing `<newId>/context.json` would silently
* discard everything stored under `<oldId>`. Every list is merged by identity
* so neither side loses entries.
*
* A version 1 context stored notes as a single string. It is left untouched
* here: `project-context` converts it on read, and converting in two places
* would mean two definitions of the same migration.
*/
const mergeProjectContextFiles = async (oldStorageDir, newStorageDir) => {
const oldContextPath = path.join(oldStorageDir, 'context.json');
const newContextPath = path.join(newStorageDir, 'context.json');
const [oldContext, newContext] = await Promise.all([
readJsonFile(oldContextPath).catch(() => null),
readJsonFile(newContextPath).catch(() => null),
]);
if (!oldContext || !newContext) {
// Nothing to reconcile: the plain directory move handles a single side.
return;
}
const mergeNotes = () => {
// One side may still be a version 1 string; keep whichever is a list, and
// prefer the destination when both are strings.
const oldIsList = Array.isArray(oldContext.notes);
const newIsList = Array.isArray(newContext.notes);
if (oldIsList && newIsList) {
return mergeByKey(oldContext.notes, newContext.notes, (item) => item.id);
}
if (newIsList) return newContext.notes;
if (oldIsList) return oldContext.notes;
return newContext.notes || oldContext.notes || '';
};
await writeJsonFile(newContextPath, {
...oldContext,
...newContext,
notes: mergeNotes(),
todos: mergeByKey(oldContext.todos, newContext.todos, (item) => item.id),
plans: mergeByKey(oldContext.plans, newContext.plans, (item) => item.id || item.file),
});
await fsPromises.rm(oldContextPath, { force: true });
};
const migrateProjectScopedStorage = async ({ oldId, newId, projectPath }) => {
if (!oldId || !newId || oldId === newId) {
return;
@@ -232,6 +282,7 @@ export const createSettingsRuntime = (deps) => {
await writeJsonFile(newConfigPath, merged);
}
await mergeProjectContextFiles(oldStorageDir, newStorageDir);
await moveDirectoryContents(oldStorageDir, newStorageDir);
await fsPromises.rm(oldConfigPath, { force: true });
};
+120 -16
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,42 @@ 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 isCommentOnlyParse(parsed, errors) {
// Comment-only / whitespace-only files parse to undefined with nothing but
// ValueExpected. Any other error means real content we failed to understand
// (YAML, plain text, a stray leading token), which must not read as empty.
return parsed === undefined
&& errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected');
}
function parseConfigObject(content, filePath) {
const errors = [];
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
if (isCommentOnlyParse(parsed, errors)) {
return {};
}
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 +214,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');
}
@@ -209,20 +250,47 @@ function mergeConfigs(base, override) {
return result;
}
function readConfigLayer(filePath) {
try {
return { config: readConfigFile(filePath), error: null };
} catch (error) {
if (isInvalidJsoncError(error)) {
console.error(error.message);
return { config: {}, error };
}
throw error;
}
}
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);
const userLayer = readConfigLayer(userPath);
const projectLayer = readConfigLayer(projectPath);
const customLayer = readConfigLayer(customPath);
const mergedConfig = mergeConfigs(
mergeConfigs(userLayer.config, projectLayer.config),
customLayer.config,
);
const layerErrors = [];
if (userLayer.error) {
layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
}
if (projectLayer.error && projectPath) {
layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
}
if (customLayer.error && customPath) {
layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message });
}
return {
userConfig,
projectConfig,
customConfig,
userConfig: userLayer.config,
projectConfig: projectLayer.config,
customConfig: customLayer.config,
mergedConfig,
paths: { userPath, projectPath, customPath }
paths: { userPath, projectPath, customPath },
layerErrors,
};
}
@@ -246,6 +314,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,23 +329,49 @@ 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');
}
}
function getLayerError(layers, filePath) {
if (!filePath || !Array.isArray(layers?.layerErrors)) {
return null;
}
return layers.layerErrors.find((entry) => entry.path === filePath) || null;
}
function throwIfLayerError(layers, filePath) {
const failed = getLayerError(layers, filePath);
if (!failed) {
return;
}
const error = new Error(failed.message);
error.code = failed.code;
throw error;
}
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 };
if (paths.customPath) {
throwIfLayerError(layers, paths.customPath);
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 };
if (paths.projectPath && !getLayerError(layers, paths.projectPath)) {
const projectSection = projectConfig?.[sectionKey]?.[entryName];
if (projectSection !== undefined) {
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
}
}
throwIfLayerError(layers, paths.userPath);
const userSection = userConfig?.[sectionKey]?.[entryName];
if (userSection !== undefined) {
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
@@ -283,11 +383,14 @@ function getJsonEntrySource(layers, sectionKey, entryName) {
function getJsonWriteTarget(layers, preferredScope) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
throwIfLayerError(layers, paths.customPath);
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
throwIfLayerError(layers, paths.projectPath);
return { config: projectConfig, path: paths.projectPath };
}
throwIfLayerError(layers, paths.userPath);
return { config: userConfig, path: paths.userPath };
}
@@ -547,6 +650,7 @@ export {
parseMdFile,
writeMdFile,
readConfigFile,
readConfigLayer,
isPlainObject,
readConfigLayers,
readConfig,
+185 -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, readConfigLayers, 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,186 @@ 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;
}
});
it('returns an empty object for a comment-only config file', () => {
const file = writeFixture('comments.jsonc', '// placeholder\n/* still empty */\n');
expect(readConfigFile(file)).toEqual({});
});
it('throws INVALID_JSONC for content that yields no JSON value at all', () => {
const yamlish = writeFixture('yamlish.jsonc', 'mcp:\n openproject:\n type: remote\n');
expect(() => readConfigFile(yamlish)).toThrow(/cannot be loaded safely/);
expect(() => writeConfig({ $schema: 'https://opencode.ai/config.json' }, yamlish)).toThrow(
/cannot be loaded safely/,
);
expect(fs.readFileSync(yamlish, 'utf8')).toBe('mcp:\n openproject:\n type: remote\n');
expect(fs.existsSync(`${yamlish}.openchamber.backup`)).toBe(false);
});
it('keeps a valid custom layer readable when a project layer is unparseable', () => {
const custom = writeFixture('custom.jsonc', VALID_CONFIG);
const projectDir = path.join(FIXTURE_DIR, 'project');
const projectFile = writeFixture(path.join('project', '.opencode', 'opencode.jsonc'), PARTIAL_PARSE_CONFIG);
const previousOpenCodeConfig = process.env.OPENCODE_CONFIG;
try {
process.env.OPENCODE_CONFIG = custom;
const layers = readConfigLayers(projectDir);
expect(layers.customConfig.plugin).toEqual(['opencode-see-image']);
expect(layers.projectConfig).toEqual({});
expect(layers.mergedConfig.plugin).toEqual(['opencode-see-image']);
expect(layers.layerErrors).toEqual([
expect.objectContaining({
path: projectFile,
code: 'INVALID_JSONC',
}),
]);
updateMcpConfig('openproject', { enabled: false }, projectDir);
const rewritten = JSON.parse(fs.readFileSync(custom, 'utf8'));
expect(rewritten.plugin).toEqual(['opencode-see-image']);
expect(rewritten.mcp.openproject.enabled).toBe(false);
expect(fs.readFileSync(projectFile, 'utf8')).toBe(PARTIAL_PARSE_CONFIG);
expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false);
} finally {
if (previousOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
else process.env.OPENCODE_CONFIG = previousOpenCodeConfig;
}
});
});