fix: complete config path and review follow-ups (#3348)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 02:19:59 +03:00
committed by GitHub
parent 0543b954c3
commit 260bfe666b
14 changed files with 119 additions and 29 deletions
@@ -1,4 +1,5 @@
import React from 'react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
@@ -30,7 +31,11 @@ import {
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md';
const agentsMdResponseSchema = z.object({
content: z.string(),
exists: z.boolean(),
path: z.string().min(1).optional(),
});
const readApiError = async (response: Response, fallback: string) => {
const data = await response.json().catch(() => null) as { error?: unknown } | null;
@@ -104,6 +109,7 @@ export const BehaviorPage: React.FC = () => {
const { t } = useI18n();
const isVSCode = useIsVSCodeRuntime();
const [prompt, setPrompt] = React.useState('');
const [agentsMdPath, setAgentsMdPath] = React.useState('AGENTS.md');
const [optimizeSystemPrompt, setOptimizeSystemPrompt] = React.useState(false);
const [responseStyleEnabled, setResponseStyleEnabled] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleEnabled);
const [responseStylePreset, setResponseStylePreset] = React.useState<ResponseStyleValue>(DEFAULT_BEHAVIOR_SETTINGS.responseStylePreset);
@@ -154,9 +160,11 @@ export const BehaviorPage: React.FC = () => {
}
}
if (!nextSettings.prompt.trim() && agentsMdRes.ok) {
const agentsData = await agentsMdRes.json();
if (typeof agentsData.content === 'string') {
if (agentsMdRes.ok) {
const agentsData = agentsMdResponseSchema.parse(await agentsMdRes.json());
if (abort.signal.aborted) return;
setAgentsMdPath(agentsData.path ?? 'AGENTS.md');
if (!nextSettings.prompt.trim()) {
nextSettings = { ...nextSettings, prompt: agentsData.content };
}
}
@@ -326,7 +334,7 @@ export const BehaviorPage: React.FC = () => {
{t('settings.behavior.page.warning.title')}
</p>
<p>
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
{t('settings.behavior.page.warning.description', { path: agentsMdPath })}
</p>
</div>
)}
+4 -6
View File
@@ -1301,12 +1301,10 @@ class OpencodeService {
* endpoint introduced in OpenCode SDK v1.17.12. Wraps
* `session.permission.get`.
*
* Returns a tagged `FetchPermissionResult` so the caller can distinguish
* a confirmed-resolved permission (HTTP 404) from a fetch failure
* (network error, malformed response, or pre-v1.17.12 server without
* the V2 endpoint). The auto-accept flow uses this distinction to drop
* resolved permissions from the resync output, preventing stale
* `permission.list` entries from sticking around in the UI.
* Returns the state of the V2 permission authority. Its HTTP 404 result
* does not prove that a request from `permission.list` has settled:
* list-derived reconciliation must use that list's own reply path.
* Fetch failures remain distinct from the V2 resolved result.
*/
async fetchPermission(
sessionID: string,
+11
View File
@@ -172,3 +172,14 @@ Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search
Maintenance: reviews, changelog entries, and parity claims consult this map;
whoever mounts or unmounts a surface updates it in the same change.
## Global OpenCode paths
`opencodeConfigPaths.ts` owns the global config directory for config CRUD,
skill discovery/install, global AGENTS.md, and quota config-file lookup. It
resolves `$XDG_CONFIG_HOME/opencode` at extension startup, falling back to
`~/.config/opencode` when unset or blank. Project paths, the explicit
`OPENCODE_CONFIG` file layer, and the auth data directory stay separate.
No files are migrated. The behavior GET bridge response includes the effective
`path` for both existing and missing AGENTS.md files; shared Settings uses it
in the warning.
+4 -4
View File
@@ -1,6 +1,6 @@
import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths';
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
createAgent,
@@ -78,7 +78,7 @@ type ConfigRuntimeDeps = {
clientReloadDelayMs: number;
};
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
const AGENTS_MD_PATH = path.join(OPENCODE_CONFIG_DIR, 'AGENTS.md');
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024;
const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => (
@@ -186,10 +186,10 @@ export async function handleConfigBridgeMessage(
case 'api:behavior/agents-md:get': {
try {
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
return { id, type, success: true, data: { content, exists: true } };
return { id, type, success: true, data: { content, exists: true, path: AGENTS_MD_PATH } };
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return { id, type, success: true, data: { content: '', exists: false } };
return { id, type, success: true, data: { content: '', exists: false, path: AGENTS_MD_PATH } };
}
throw error;
}
@@ -1,3 +1,4 @@
import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -76,7 +77,7 @@ const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory
const home = os.homedir();
const userRoots = [
path.join(home, '.config', 'opencode'),
OPENCODE_CONFIG_DIR,
path.join(home, '.opencode'),
path.join(home, '.claude', 'skills'),
path.join(home, '.agents', 'skills'),
+1 -1
View File
@@ -1,10 +1,10 @@
import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import yaml from 'yaml';
import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const moduleUrl = new URL('./opencodeConfigPaths.ts', import.meta.url).href;
const configUrl = new URL('./opencodeConfig.ts', import.meta.url).href;
test('unset and blank XDG keep the existing global directory', () => {
for (const value of [undefined, '', ' ']) {
const env = { ...process.env };
if (value === undefined) delete env.XDG_CONFIG_HOME;
else env.XDG_CONFIG_HOME = value;
const result = spawnSync(process.execPath, ['--eval', `
const { OPENCODE_CONFIG_DIR } = await import(${JSON.stringify(moduleUrl)});
process.stdout.write(OPENCODE_CONFIG_DIR);
`], { env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, path.join(os.homedir(), '.config', 'opencode'));
}
});
test('global CRUD uses XDG while project writes stay in the project', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-xdg-'));
const configHome = path.join(root, 'config');
const project = path.join(root, 'project');
try {
const result = spawnSync(process.execPath, ['--eval', `
const { createAgent, createCommand, createSkill } = await import(${JSON.stringify(configUrl)});
const project = ${JSON.stringify(project)};
for (const scope of ['user', 'project']) {
createAgent(scope + '-agent', { description: 'Test', prompt: 'Test prompt' }, project, scope);
createCommand(scope + '-command', { description: 'Test', template: 'Test command' }, project, scope);
createSkill(scope + '-skill', { description: 'Test', instructions: 'Test skill' }, project, scope);
}
`], {
env: { ...process.env, XDG_CONFIG_HOME: configHome, OPENCODE_CONFIG: '', OPENCODE_CONFIG_DIR: '' },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
for (const [scope, directory] of [['user', path.join(configHome, 'opencode')], ['project', path.join(project, '.opencode')]]) {
assert.match(fs.readFileSync(path.join(directory, 'agents', `${scope}-agent.md`), 'utf8'), /Test prompt/);
assert.match(fs.readFileSync(path.join(directory, 'commands', `${scope}-command.md`), 'utf8'), /Test command/);
assert.match(fs.readFileSync(path.join(directory, 'skills', `${scope}-skill`, 'SKILL.md'), 'utf8'), /Test skill/);
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,8 @@
import os from 'node:os';
import path from 'node:path';
// Resolve once at extension startup, matching the web backend.
export const OPENCODE_CONFIG_DIR = path.join(
process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'),
'opencode',
);
+1 -1
View File
@@ -1,3 +1,4 @@
import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -191,7 +192,6 @@ export type ProviderResult = {
planLabel?: string | null;
};
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
+3 -2
View File
@@ -1,3 +1,4 @@
import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths';
import fs from 'fs';
import os from 'os';
import path from 'path';
@@ -418,8 +419,8 @@ async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) {
}
function getUserSkillBaseDir() {
const pluralPath = path.join(os.homedir(), '.config', 'opencode', 'skills');
const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'skill');
const pluralPath = path.join(OPENCODE_CONFIG_DIR, 'skills');
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill');
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
}
+5 -5
View File
@@ -228,11 +228,11 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
return {
startingApi: 'OpenCode API başlatılıyor…',
initializing: 'Başlatılıyor…',
connecting: 'Bağlanılıyor…',
connecting: 'Bağlanıyor…',
connected: 'Bağlandı!',
connectionError: 'Bağlantı hatası',
reconnecting: 'Yeniden bağlanılıyor…',
cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce yükleyin.',
reconnecting: 'Yeniden bağlanıyor…',
cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce kurun.',
};
}
return {
@@ -310,8 +310,8 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
}
if (detected.indexOf('tr') === 0) {
return {
startingDevServer: (host) => 'Webview geliştirme sunucusu başlatılıyor (' + host + ')...',
waitingDevServer: (host, attempt) => 'Webview geliştirme sunucusu bekleniyor (' + host + ')... deneme ' + attempt,
startingDevServer: (host) => 'Webview dev sunucusu başlatılıyor (' + host + ')...',
waitingDevServer: (host, attempt) => 'Webview dev sunucusu bekleniyor (' + host + ')... deneme ' + attempt,
};
}
}
@@ -421,7 +421,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Storage and configuration
- Provider auth: `~/.local/share/opencode/auth.json`.
- User config: `~/.config/opencode/opencode.json`.
- User config: `$XDG_CONFIG_HOME/opencode/opencode.json`, falling back to `~/.config/opencode/opencode.json` when unset or blank.
- Project config: `<workingDirectory>/.opencode/opencode.json` or `opencode.json`.
- Custom config: `OPENCODE_CONFIG` env var path.
- Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars.
@@ -433,3 +433,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- Config merging follows priority: custom > project > user.
- UI auth uses scrypt for password hashing with constant-time comparison.
- Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback.
The behavior `GET /api/behavior/agents-md` response includes `path`, the effective
server-side filename, whether or not the file exists. Settings displays this
path without deriving a directory from the browser environment.
@@ -99,10 +99,18 @@ describe('OpenCode global config paths', () => {
routes.registerOpenCodeRoutes(app, {});
const response = { json: vi.fn(), status: vi.fn(() => response) };
await handlers.get('GET /api/behavior/agents-md')({}, response);
expect(response.json).toHaveBeenLastCalledWith({
content: '', exists: false, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
});
await handlers.get('PUT /api/behavior/agents-md')({ body: { content: 'Global behavior' } }, response);
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
expect(response.json).toHaveBeenCalled();
await handlers.get('GET /api/behavior/agents-md')({}, response);
expect(response.json).toHaveBeenLastCalledWith({
content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'),
});
fs.rmSync(root, { recursive: true, force: true });
});
});
+2 -2
View File
@@ -815,10 +815,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
try {
await fs.promises.access(AGENTS_MD_PATH);
} catch {
return res.json({ content: '', exists: false });
return res.json({ content: '', exists: false, path: AGENTS_MD_PATH });
}
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
return res.json({ content, exists: true });
return res.json({ content, exists: true, path: AGENTS_MD_PATH });
} catch (error) {
console.error('Failed to read AGENTS.md:', error);
return res.status(500).json({ error: 'Failed to read AGENTS.md' });