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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user