From b2e327ccb7b8b4dcf492fd304579215d21052ed7 Mon Sep 17 00:00:00 2001 From: Lars Moan Date: Tue, 25 Aug 2026 14:31:16 +0000 Subject: [PATCH 1/2] fix(opencode): honor the user's own opencode config ucode exported XDG_CONFIG_HOME to send opencode to its own directory. That removed all of ~/.config/opencode from opencode's load path. Thus the user's permission rules, MCP servers, disabled_providers, global skills, agents, commands, plugins, tui.json and global AGENTS.md had no effect in a ucode session. Name ucode's file with OPENCODE_CONFIG in place of the redirect. OPENCODE_CONFIG is a config layer above the user's global config, and opencode merges the layers. Objects merge one key at a time and the later scalar wins. Thus ucode keeps control of `model` and `provider`, and the user keeps everything else. ucode still writes to its own file only. The config file path does not change, so MCP servers that `ucode mcp add` registered survive the upgrade. --- README.md | 2 +- src/ucode/agents/opencode.py | 17 ++++++++++++++--- tests/test_agent_opencode.py | 21 +++++++++++++++------ tests/test_e2e.py | 10 ++++++---- tests/test_e2e_user_agent.py | 12 +++++++++--- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3dfe8bb5..eea25c8b 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ their next ucode run. | `~/.codex/config.toml` | Codex | | `~/.claude/settings.json` | Claude Code | | `~/.gemini/.env` | Gemini CLI | -| `~/.config/opencode/opencode.json` | OpenCode | +| `~/.ucode/opencode-xdg/opencode/opencode.json` | OpenCode — a config layer above your own `~/.config/opencode/opencode.json`, which OpenCode still reads | | `~/.copilot/.env` | GitHub Copilot CLI | | `~/.pi/agent/models.json` | Pi | | `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) | diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index 19adff71..c0910686 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -25,8 +25,11 @@ from ucode.state import mark_tool_managed, save_state from ucode.telemetry import agent_version, ucode_version -OPENCODE_XDG_CONFIG_HOME = APP_DIR / "opencode-xdg" -OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode" +# ucode keeps its config outside `~/.config/opencode` so it never writes to the +# user's own config. The `opencode-xdg` directory name is historical: ucode used +# to export it as XDG_CONFIG_HOME. The path stays as it is so MCP servers that +# `ucode mcp add` already registered survive an upgrade. +OPENCODE_CONFIG_DIR = APP_DIR / "opencode-xdg" / "opencode" OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json" OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json" @@ -260,7 +263,15 @@ def _refresh_forever(state: dict, stop_event: threading.Event) -> None: def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]: env = os.environ.copy() env["OAUTH_TOKEN"] = token - env["XDG_CONFIG_HOME"] = str(OPENCODE_XDG_CONFIG_HOME) + # Name the config file instead of a redirect of XDG_CONFIG_HOME. A redirect + # hides all of `~/.config/opencode`: permissions, the user's MCP servers, + # `disabled_providers`, global skills, agents, commands, plugins, tui.json + # and the global AGENTS.md. OPENCODE_CONFIG is a layer above the user's + # global config, and opencode merges the layers — it does not replace them. + # Objects merge one key at a time and the later scalar wins, so ucode keeps + # control of `model` and `provider`. Do not put a top-level array in this + # file: opencode replaces an array, it does not merge it. + env["OPENCODE_CONFIG"] = str(OPENCODE_CONFIG_PATH) return env diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index c83e8458..de7c447c 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path from unittest.mock import patch from ucode.agents import opencode @@ -28,10 +29,9 @@ def test_package(self): def test_display(self): assert opencode.SPEC["display"] == "OpenCode" - def test_config_path_is_under_ucode_xdg_home(self): - assert opencode.SPEC["config_path"] == ( - opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json" - ) + def test_config_path_is_outside_the_user_config_dir(self): + assert opencode.SPEC["config_path"] == opencode.OPENCODE_CONFIG_DIR / "opencode.json" + assert Path.home() / ".config" not in opencode.OPENCODE_CONFIG_PATH.parents class TestRenderOverlay: @@ -307,10 +307,19 @@ def test_sets_oauth_token_for_mcp(self): assert env["OAUTH_TOKEN"] == "tok" - def test_sets_ucode_xdg_config_home(self): + def test_names_the_ucode_config_file(self): + env = opencode.build_runtime_env("tok") + + assert env["OPENCODE_CONFIG"] == str(opencode.OPENCODE_CONFIG_PATH) + + def test_leaves_xdg_config_home_alone(self, monkeypatch): + # A redirect of XDG_CONFIG_HOME hides the whole of ~/.config/opencode: + # permissions, the user's MCP servers, skills, agents and tui.json. + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + env = opencode.build_runtime_env("tok") - assert env["XDG_CONFIG_HOME"] == str(opencode.OPENCODE_XDG_CONFIG_HOME) + assert "XDG_CONFIG_HOME" not in env class TestOpencodeDefaultModel: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index beb73318..b511ba53 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -782,12 +782,14 @@ def test_launch_opencode_per_model( pytest.skip("No OpenCode models available on this workspace") monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) - xdg = tmp_path / "opencode-xdg" - config_path = xdg / "opencode" / "opencode.json" + config_path = tmp_path / "opencode-xdg" / "opencode" / "opencode.json" backup_path = tmp_path / "opencode-config.backup.json" - monkeypatch.setattr(opencode, "OPENCODE_XDG_CONFIG_HOME", xdg) monkeypatch.setattr(opencode, "OPENCODE_CONFIG_PATH", config_path) monkeypatch.setattr(opencode, "OPENCODE_BACKUP_PATH", backup_path) + # ucode no longer redirects XDG_CONFIG_HOME, so the spawned opencode + # would read the developer's own ~/.config/opencode. Send it to an empty + # directory to keep the test hermetic. + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty-xdg")) import sys import time @@ -861,11 +863,11 @@ def test_launch_deepseek_v4_pro( monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path) xdg = tmp_path / "opencode-xdg" - monkeypatch.setattr(opencode, "OPENCODE_XDG_CONFIG_HOME", xdg) monkeypatch.setattr(opencode, "OPENCODE_CONFIG_PATH", xdg / "opencode" / "opencode.json") monkeypatch.setattr( opencode, "OPENCODE_BACKUP_PATH", tmp_path / "opencode-config.backup.json" ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty-xdg")) monkeypatch.setattr("ucode.state.save_state", lambda state: None) monkeypatch.setattr( "ucode.agents.opencode.get_databricks_token", diff --git a/tests/test_e2e_user_agent.py b/tests/test_e2e_user_agent.py index e6cec214..51aed1f6 100644 --- a/tests/test_e2e_user_agent.py +++ b/tests/test_e2e_user_agent.py @@ -242,8 +242,6 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv from ucode.agents import opencode _require_binary("opencode") - # Redirect via XDG_CONFIG_HOME so the spawned opencode reads from - # tmp_path instead of the developer's real ~/.config/opencode. xdg = tmp_path / "xdg" opencode_dir = xdg / "opencode" opencode_dir.mkdir(parents=True) @@ -272,7 +270,15 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv ) opencode.write_tool_config(state, "test-claude-model", token="test-token") - env = {**os.environ, "OAUTH_TOKEN": "test-token", "XDG_CONFIG_HOME": str(xdg)} + # OPENCODE_CONFIG names ucode's file. XDG_CONFIG_HOME goes to an empty + # directory so the spawned opencode does not read the developer's own + # ~/.config/opencode. + env = { + **os.environ, + "OAUTH_TOKEN": "test-token", + "OPENCODE_CONFIG": str(config_path), + "XDG_CONFIG_HOME": str(tmp_path / "empty-xdg"), + } result = _run_until_first_request(opencode.validate_cmd("opencode"), env) req = capture_server.first_request_with_path_prefix("/ai-gateway/anthropic") From ed5de058625962f6c98161ea1fc6a5f25329d463 Mon Sep 17 00:00:00 2001 From: Lars Moan Date: Tue, 25 Aug 2026 23:59:16 +0000 Subject: [PATCH 2/2] fix(opencode): guard the XDG_CONFIG_HOME pass-through The single test asserted only that build_runtime_env adds no XDG_CONFIG_HOME when the variable is absent. A reintroduced redirect that overwrites a value the user already set would have passed. Split the case in two so the pass-through is asserted with a sentinel. --- tests/test_agent_opencode.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index de7c447c..786d5b1c 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -312,9 +312,17 @@ def test_names_the_ucode_config_file(self): assert env["OPENCODE_CONFIG"] == str(opencode.OPENCODE_CONFIG_PATH) - def test_leaves_xdg_config_home_alone(self, monkeypatch): - # A redirect of XDG_CONFIG_HOME hides the whole of ~/.config/opencode: - # permissions, the user's MCP servers, skills, agents and tui.json. + # A redirect of XDG_CONFIG_HOME hides the whole of ~/.config/opencode: + # permissions, the user's MCP servers, skills, agents and tui.json. So ucode + # must neither add the variable nor overwrite the value the user set. + def test_passes_the_users_xdg_config_home_through(self, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", "/sentinel/xdg") + + env = opencode.build_runtime_env("tok") + + assert env["XDG_CONFIG_HOME"] == "/sentinel/xdg" + + def test_adds_no_xdg_config_home(self, monkeypatch): monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) env = opencode.build_runtime_env("tok")