diff --git a/README.md b/README.md index 3d61d639..74469e1b 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ control the installation. | `~/.codex/ucode.config.toml` (or legacy `~/.codex/config.toml`) | Codex | | `~/.claude/ucode-settings.json` | Claude Code settings generated by ucode | | `~/.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..32e25514 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. Moving this path drops the `mcp` entries that `ucode mcp add` +# wrote here; re-running the command does not restore them, because ucode writes +# only when the server list changes. +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,10 @@ 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) + # opencode merges this file over the user's global config. It replaces a + # top-level array instead of merging it, so this file carries no top-level + # array — one would wipe the user's `disabled_providers`. + 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..409d62b1 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,9 +29,10 @@ def test_package(self): def test_display(self): assert opencode.SPEC["display"] == "OpenCode" - def test_config_path_is_under_ucode_xdg_home(self): + def test_config_path_stays_at_the_registered_location(self): + # A change here orphans the MCP servers that `ucode mcp add` already wrote. assert opencode.SPEC["config_path"] == ( - opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json" + Path.home() / ".ucode" / "opencode-xdg" / "opencode" / "opencode.json" ) @@ -307,10 +309,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["XDG_CONFIG_HOME"] == str(opencode.OPENCODE_XDG_CONFIG_HOME) + 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 "XDG_CONFIG_HOME" not in env class TestOpencodeDefaultModel: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 01127a12..90711cc3 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 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")