diff --git a/README.md b/README.md index b79b920..9c47b57 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ The output looks like: | `~/.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 19adff7..c091068 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 c83e845..786d5b1 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,27 @@ 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) + + # 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") - 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 01127a1..90711cc 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -784,12 +784,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 @@ -863,11 +865,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 e6cec21..51aed1f 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")