From 3cc504aa7e482af400f4aec5ce35fbbaaeabe43b Mon Sep 17 00:00:00 2001 From: Rohit Agrawal Date: Wed, 19 Aug 2026 10:22:23 -0700 Subject: [PATCH 1/3] Refresh OpenCode Databricks auth on demand --- src/ucode/agents/opencode.py | 177 ++++++++++++++++++++++++++++------- src/ucode/cli.py | 6 +- tests/test_agent_opencode.py | 80 +++++++++++++++- tests/test_agents_init.py | 16 +++- tests/test_cli.py | 19 +++- 5 files changed, 256 insertions(+), 42 deletions(-) diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index 19adff71..9675b12c 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -2,10 +2,11 @@ from __future__ import annotations +import json import os +import re import signal import subprocess -import threading from ucode.agent_updates import available_npm_package_update from ucode.config_io import ( @@ -15,9 +16,10 @@ deep_merge_dict, read_json_safe, write_json_file, + write_text_file, ) from ucode.databricks import ( - TOKEN_REFRESH_INTERVAL_SECONDS, + build_auth_token_argv, build_opencode_base_urls, get_databricks_token, model_token_limits, @@ -29,10 +31,16 @@ OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode" OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json" OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json" +OPENCODE_AUTH_PLUGIN_PATH = OPENCODE_CONFIG_DIR / "plugin" / "ucode-auth.js" +OPENCODE_NPM_PACKAGE = "opencode-ai" +MINIMUM_OPENCODE_VERSION = (1, 0, 220) +MINIMUM_OPENCODE_VERSION_TEXT = "1.0.220" SPEC: ToolSpec = { "binary": "opencode", - "package": "opencode-ai", + # npm's `latest` tag currently points at an old 0.0.0 beta. Stay on the + # stable v1 line for both fresh installs and updates. + "package": f"{OPENCODE_NPM_PACKAGE}@1", "display": "OpenCode", "config_path": OPENCODE_CONFIG_PATH, "backup_path": OPENCODE_BACKUP_PATH, @@ -44,9 +52,136 @@ ["provider", "databricks-oss"], ] +_AUTH_PLUGIN_TEMPLATE = """// Generated by ucode. Keep Databricks auth fresh for model requests. +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const DATABRICKS_PROVIDERS = new Set([ + "databricks-anthropic", + "databricks-google", + "databricks-oss", +]) +const AUTH_COMMAND = __AUTH_COMMAND__ +const REFRESH_SKEW_MS = 120_000 +const run = promisify(execFile) + +let accessToken +let expiresAt = 0 +let refreshPromise + +function cacheToken(value) { + accessToken = value + expiresAt = Infinity + try { + const claims = JSON.parse(Buffer.from(value.split(".")[1], "base64url").toString()) + if (typeof claims.exp === "number") expiresAt = claims.exp * 1000 + } catch {} +} + +async function mintToken() { + try { + const { stdout } = await run(AUTH_COMMAND[0], AUTH_COMMAND.slice(1), { encoding: "utf8" }) + const token = stdout.trim() + if (!token) throw new Error("returned an empty token") + cacheToken(token) + } catch (error) { + const detail = String(error.stderr || error.message || "").trim() + throw new Error("ucode auth-token failed" + (detail ? ": " + detail : "")) + } +} + +function refreshToken() { + if (!refreshPromise) refreshPromise = mintToken().finally(() => { refreshPromise = undefined }) + return refreshPromise +} + +function requestWithToken(input, init, token) { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) + headers.set("Authorization", "Bearer " + token) + return { ...init, headers } +} + +async function databricksFetch(input, init) { + if (!accessToken || expiresAt <= Date.now() + REFRESH_SKEW_MS) await refreshToken() + const attemptedToken = accessToken + const response = await fetch(input, requestWithToken(input, init, attemptedToken)) + if (response.status !== 401) return response + + // Another concurrent request may already have refreshed this token. + if (accessToken === attemptedToken) await refreshToken() + return fetch(input, requestWithToken(input, init, accessToken)) +} + +export const UcodeDatabricksAuth = async () => ({ + config: async (config) => { + for (const providerID of DATABRICKS_PROVIDERS) { + const options = config.provider?.[providerID]?.options + if (!options) continue + if (!accessToken && typeof options.apiKey === "string") cacheToken(options.apiKey) + options.fetch = databricksFetch + } + }, +}) +""" + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) + return tuple(map(int, match.groups())) if match else None + + +def _minimum_version_message() -> str | None: + installed = agent_version(SPEC["binary"]) + parsed = _parse_version(installed) + if parsed is None or parsed >= MINIMUM_OPENCODE_VERSION: + return None + return ( + f"OpenCode {installed} is too old. ucode requires OpenCode " + f"{MINIMUM_OPENCODE_VERSION_TEXT} or newer for renewable Databricks authentication." + ) + + +def required_update_message() -> str | None: + return _minimum_version_message() + + +def minimum_version_error() -> str | None: + message = _minimum_version_message() + if message is None: + return None + return f"{message} Update it with `npm install -g {SPEC['package']}`." + def is_update_available() -> tuple[str, str] | None: - return available_npm_package_update(SPEC["package"]) + """Offer only stable OpenCode v1 updates, never npm's beta `latest` tag.""" + update = available_npm_package_update(OPENCODE_NPM_PACKAGE) + if update is None: + return None + _, target = update + parsed = _parse_version(target) + if "-" in target or parsed is None or parsed[0] != 1: + return None + return update + + +def render_auth_plugin(state: dict) -> str: + """Render the local OpenCode plugin that refreshes Databricks auth on demand.""" + argv = build_auth_token_argv( + state["workspace"], + state.get("profile"), + use_pat=bool(state.get("use_pat")), + ) + # A 401 must not return the same still-unexpired cached credential. + argv.append("--force-refresh") + return _AUTH_PLUGIN_TEMPLATE.replace("__AUTH_COMMAND__", json.dumps(argv)) + + +def write_auth_plugin(state: dict) -> None: + """Install the auto-discovered hook in ucode's isolated OpenCode config.""" + # Derive the path so tests that redirect OPENCODE_CONFIG_PATH stay isolated. + path = OPENCODE_CONFIG_PATH.parent / "plugin" / OPENCODE_AUTH_PLUGIN_PATH.name + write_text_file(path, render_auth_plugin(state)) def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str: @@ -158,14 +293,10 @@ def write_tool_config( state: dict, model: str, token: str | None = None, - *, - force_refresh: bool = False, ) -> tuple[dict, str]: backup_existing_file(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH) if token is None: - token = get_databricks_token( - state["workspace"], state.get("profile"), force_refresh=force_refresh - ) + token = get_databricks_token(state["workspace"], state.get("profile")) opencode_base_urls = state.get("base_urls", {}).get("opencode") or build_opencode_base_urls( state["workspace"] ) @@ -176,6 +307,7 @@ def write_tool_config( state.get("opencode_models") or {}, ) existing = read_json_safe(OPENCODE_CONFIG_PATH) + write_auth_plugin(state) providers = existing.get("provider") if isinstance(providers, dict): for stale in ( @@ -241,22 +373,14 @@ def default_model(state: dict) -> str | None: return oss[0] if oss else None -def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str: +def _configure_launch(state: dict) -> str: model = default_model(state) if not model: raise RuntimeError("No OpenCode model is configured.") - _, token = write_tool_config(state, model, force_refresh=force_refresh) + _, token = write_tool_config(state, model) return token -def _refresh_forever(state: dict, stop_event: threading.Event) -> None: - while not stop_event.wait(TOKEN_REFRESH_INTERVAL_SECONDS): - try: - _refresh_token_once(state, force_refresh=True) - except RuntimeError: - continue - - def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]: env = os.environ.copy() env["OAUTH_TOKEN"] = token @@ -265,27 +389,16 @@ def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]: def launch(state: dict, tool_args: list[str]) -> None: - """Launch opencode with background token refresh (same pattern as Gemini).""" - token = _refresh_token_once(state) + """Launch OpenCode with on-demand token refresh from its local plugin.""" + token = _configure_launch(state) env = build_runtime_env(token, state) - stop_event = threading.Event() - refresher = threading.Thread( - target=_refresh_forever, - args=(state, stop_event), - daemon=True, - ) - refresher.start() - proc = subprocess.Popen([SPEC["binary"], *tool_args], env=env) try: returncode = proc.wait() except KeyboardInterrupt: proc.send_signal(signal.SIGINT) returncode = proc.wait() - finally: - stop_event.set() - refresher.join(timeout=1) raise SystemExit(returncode) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 910af654..d1fdd0d3 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1180,6 +1180,10 @@ def auth_token_cmd( use_pat: Annotated[ bool, typer.Option("--use-pat", help="Read the profile's static PAT instead of OAuth.") ] = False, + force_refresh: Annotated[ + bool, + typer.Option("--force-refresh", help="Force the Databricks CLI to mint a new token."), + ] = False, ) -> None: """Print a Databricks bearer token to stdout, then exit. @@ -1210,7 +1214,7 @@ def auth_token_cmd( ) raise typer.Exit(1) try: - token = get_databricks_token(workspace, profile) + token = get_databricks_token(workspace, profile, force_refresh=force_refresh) except RuntimeError as exc: print_err(str(exc)) raise typer.Exit(1) from None diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index c83e8458..96c162dc 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -23,7 +23,7 @@ def test_binary(self): assert opencode.SPEC["binary"] == "opencode" def test_package(self): - assert opencode.SPEC["package"] == "opencode-ai" + assert opencode.SPEC["package"] == "opencode-ai@1" def test_display(self): assert opencode.SPEC["display"] == "OpenCode" @@ -33,6 +33,80 @@ def test_config_path_is_under_ucode_xdg_home(self): opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json" ) + def test_update_check_uses_latest_stable_v1(self, monkeypatch): + monkeypatch.setattr( + opencode, + "available_npm_package_update", + lambda _package: ("1.18.15", "1.18.16"), + ) + + assert opencode.is_update_available() == ("1.18.15", "1.18.16") + + def test_update_check_ignores_npm_beta(self, monkeypatch): + monkeypatch.setattr( + opencode, + "available_npm_package_update", + lambda _package: ("1.18.16", "0.0.0-beta-202605152242"), + ) + + assert opencode.is_update_available() is None + + def test_requires_version_with_custom_provider_fetch(self, monkeypatch): + monkeypatch.setattr(opencode, "agent_version", lambda _binary: "1.0.219") + + message = opencode.minimum_version_error() + + assert message is not None + assert "requires OpenCode 1.0.220 or newer" in message + assert "npm install -g opencode-ai@1" in message + + def test_supported_version_needs_no_required_update(self, monkeypatch): + monkeypatch.setattr(opencode, "agent_version", lambda _binary: "1.0.220") + + assert opencode.required_update_message() is None + assert opencode.minimum_version_error() is None + + +class TestAuthPlugin: + def test_calls_cross_platform_auth_token_helper_only_when_refreshing(self, monkeypatch): + monkeypatch.setattr( + opencode, + "build_auth_token_argv", + lambda workspace, profile, use_pat=False: [ + "/opt/ucode", + "auth-token", + "--host", + workspace, + "--profile", + profile, + ], + ) + + plugin = opencode.render_auth_plugin({"workspace": WS, "profile": "my profile"}) + + assert ( + 'const AUTH_COMMAND = ["/opt/ucode", "auth-token", "--host", ' + f'"{WS}", "--profile", "my profile", "--force-refresh"]' + ) in plugin + assert "run(AUTH_COMMAND[0], AUTH_COMMAND.slice(1)" in plugin + assert '"chat.headers"' not in plugin + + def test_installs_cached_refreshing_fetch_on_databricks_providers(self): + plugin = opencode.render_auth_plugin({"workspace": WS}) + + assert "config: async (config)" in plugin + assert "options.fetch = databricksFetch" in plugin + assert "expiresAt <= Date.now() + REFRESH_SKEW_MS" in plugin + assert 'headers.set("Authorization", "Bearer " + token)' in plugin + assert "if (response.status !== 401) return response" in plugin + assert "return fetch(input, requestWithToken(input, init, accessToken))" in plugin + + def test_refresh_is_single_flighted(self): + plugin = opencode.render_auth_plugin({"workspace": WS}) + + assert "if (!refreshPromise)" in plugin + assert "mintToken().finally(() => { refreshPromise = undefined })" in plugin + class TestRenderOverlay: def test_sets_model(self): @@ -397,6 +471,10 @@ def test_stale_providers_removed_before_merge(self, tmp_path, monkeypatch): assert providers.get("databricks-anthropic") != {"old": True} # unmanaged provider entry survives assert providers.get("other-provider") == {"keep": True} + # OpenCode 1.0.0 discovers `plugin/`; plural `plugins/` came later. + plugin = config_file.parent / "plugin" / opencode.OPENCODE_AUTH_PLUGIN_PATH.name + assert plugin.exists() + assert "options.fetch = databricksFetch" in plugin.read_text() def test_config_written_with_correct_model(self, tmp_path, monkeypatch): import ucode.agents.opencode as oc_mod diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index f346af90..d5e73ffd 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -364,9 +364,11 @@ def fake_run(args, **kwargs): monkeypatch.setattr("ucode.agents.shutil.which", fake_which) monkeypatch.setattr("ucode.agents.subprocess.run", fake_run) monkeypatch.setattr("ucode.agents._confirm_update_installed_tool_binary", lambda _: True) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=False, update_existing=True) is True - assert calls == [["npm", "install", "-g", "opencode-ai"]] + assert calls == [["npm", "install", "-g", "opencode-ai@1"]] output = capsys.readouterr().out assert "Updating OpenCode..." in output assert "OpenCode is up to date" in output @@ -388,6 +390,8 @@ def fake_run(args, **kwargs): monkeypatch.setattr( "ucode.agents.prompt_yes_no", lambda prompt: prompt_calls.append(prompt) or True ) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=False, update_existing=True) is True assert calls == [] @@ -413,10 +417,12 @@ def fake_run(args, **kwargs): monkeypatch.setattr( "ucode.agents.prompt_yes_no", lambda prompt: prompt_calls.append(prompt) or True ) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=False, update_existing=True) is True assert prompt_calls == ["(Optional) Update OpenCode from 1.2.3 to 1.2.4?"] - assert calls == [["npm", "install", "-g", "opencode-ai"]] + assert calls == [["npm", "install", "-g", "opencode-ai@1"]] assert "Updating OpenCode..." in capsys.readouterr().out def test_skips_existing_binary_update_when_user_declines(self, monkeypatch, capsys): @@ -432,6 +438,8 @@ def fake_run(args, **kwargs): monkeypatch.setattr("ucode.agents.shutil.which", fake_which) monkeypatch.setattr("ucode.agents.subprocess.run", fake_run) monkeypatch.setattr("ucode.agents._confirm_update_installed_tool_binary", lambda _: False) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=False, update_existing=True) is True assert calls == [] @@ -489,7 +497,7 @@ def fake_run(args, **kwargs): ) is True ) - assert calls and calls[0][:3] == ["npm", "install", "-g"] + assert calls == [["npm", "install", "-g", "opencode-ai@1"]] def test_too_new_tool_warns_and_downgrades_on_confirm(self, monkeypatch, capsys): """An installed build past its supported ceiling is offered as a @@ -582,6 +590,8 @@ def fake_run(*args, **kwargs): monkeypatch.setattr("ucode.agents.shutil.which", fake_which) monkeypatch.setattr("ucode.agents.subprocess.run", fake_run) monkeypatch.setattr("ucode.agents._confirm_update_installed_tool_binary", lambda _: True) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=True, update_existing=True) is True diff --git a/tests/test_cli.py b/tests/test_cli.py index ad45a9a7..d9a74106 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -393,7 +393,7 @@ def test_prints_only_the_token_to_stdout(self): # Nothing but the bare token (plus trailing newline) may reach stdout, # or the consuming agent will treat the noise as part of the token. assert result.stdout == "tok-123\n" - fetch.assert_called_once_with("https://ws", None) + fetch.assert_called_once_with("https://ws", None, force_refresh=False) def test_host_and_profile_override_state(self): with ( @@ -404,7 +404,16 @@ def test_host_and_profile_override_state(self): app, ["auth-token", "--host", "https://override", "--profile", "prod"] ) assert result.exit_code == 0 - fetch.assert_called_once_with("https://override", "prod") + fetch.assert_called_once_with("https://override", "prod", force_refresh=False) + + def test_force_refresh_is_forwarded(self): + with ( + patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), + patch("ucode.cli.get_databricks_token", return_value="tok") as fetch, + ): + result = runner.invoke(app, ["auth-token", "--force-refresh"]) + assert result.exit_code == 0 + fetch.assert_called_once_with("https://ws", None, force_refresh=True) def test_errors_without_workspace(self): with patch("ucode.cli.load_state", return_value={}): @@ -426,7 +435,7 @@ def test_use_pat_emits_resolved_pat(self, monkeypatch): patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), patch( "ucode.cli.get_databricks_token", - side_effect=lambda w, p: os.environ.get("DATABRICKS_BEARER", ""), + side_effect=lambda w, p, **_kwargs: os.environ.get("DATABRICKS_BEARER", ""), ), ): result = runner.invoke(app, ["auth-token", "--use-pat", "--profile", "p"]) @@ -442,7 +451,7 @@ def test_use_pat_ignores_empty_bearer_env(self, monkeypatch): patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), patch( "ucode.cli.get_databricks_token", - side_effect=lambda w, p: os.environ.get("DATABRICKS_BEARER", ""), + side_effect=lambda w, p, **_kwargs: os.environ.get("DATABRICKS_BEARER", ""), ), ): result = runner.invoke(app, ["auth-token", "--use-pat", "--profile", "p"]) @@ -472,7 +481,7 @@ def test_use_pat_honors_non_empty_bearer_env(self, monkeypatch): patch("ucode.cli.load_state", return_value={"workspace": "https://ws"}), patch( "ucode.cli.get_databricks_token", - side_effect=lambda w, p: os.environ.get("DATABRICKS_BEARER", ""), + side_effect=lambda w, p, **_kwargs: os.environ.get("DATABRICKS_BEARER", ""), ), ): result = runner.invoke(app, ["auth-token", "--use-pat", "--profile", "p"]) From 88f68aa5a173a3b9daf86e8422a53774e445be9a Mon Sep 17 00:00:00 2001 From: Rohit Agrawal Date: Wed, 19 Aug 2026 10:28:18 -0700 Subject: [PATCH 2/3] Test OpenCode token refresh end to end --- .github/workflows/ci.yml | 2 +- tests/test_e2e.py | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a86c0513..b672b26f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: @anthropic-ai/claude-code @openai/codex @google/gemini-cli - opencode-ai + opencode-ai@1 @github/copilot @earendil-works/pi-coding-agent - run: uv lock diff --git a/tests/test_e2e.py b/tests/test_e2e.py index beb73318..b043dcfa 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -894,6 +894,53 @@ def test_launch_deepseek_v4_pro( f"stdout={result.stdout[:300]!r} stderr={result.stderr[:500]!r}" ) + def test_recovers_from_rejected_initial_token( + self, tmp_path, monkeypatch, e2e_state, e2e_workspace, e2e_token + ): + """Exercise the real OpenCode plugin's 401 refresh-and-retry path.""" + import ucode.config_io as config_io_mod + from ucode.agents import opencode + + _require_binary("opencode") + models = self._all_models(e2e_state) + if not models: + pytest.skip("No OpenCode models available on this workspace") + + 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.setattr("ucode.state.save_state", lambda s: None) + + _, model = models[0] + rejected_token = "ucode-intentionally-rejected-token" + opencode.write_tool_config( + {**e2e_state, "workspace": e2e_workspace}, + model, + token=rejected_token, + ) + + env = opencode.build_runtime_env(rejected_token) + # CI authenticates this way; the helper subprocess inherits it and + # returns the known-good token after OpenCode's first request gets 401. + env["DATABRICKS_BEARER"] = e2e_token + result = _run_agent( + opencode.validate_cmd("opencode"), + env=env, + timeout=180, + ) + combined = (result.stdout + result.stderr).strip() + assert result.returncode == 0 and combined, ( + "OpenCode did not recover after its initial token was rejected: " + f"rc={result.returncode} stdout={result.stdout[:300]!r} " + f"stderr={result.stderr[:500]!r}" + ) + class TestCopilotLaunch: """Run copilot against every Claude/codex model via the MLflow chat-completions gateway. From 5597c33062936b30269512b67b7e21817a1ad3a3 Mon Sep 17 00:00:00 2001 From: Rohit Agrawal Date: Wed, 19 Aug 2026 10:31:01 -0700 Subject: [PATCH 3/3] Fix OpenCode PR lint failures --- src/ucode/agents/opencode.py | 5 ++++- tests/test_e2e.py | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index 9675b12c..b07aef56 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -128,7 +128,10 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) - return tuple(map(int, match.groups())) if match else None + if match is None: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) def _minimum_version_message() -> str | None: diff --git a/tests/test_e2e.py b/tests/test_e2e.py index b043dcfa..cc9cba9d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -909,9 +909,7 @@ def test_recovers_from_rejected_initial_token( 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_CONFIG_PATH", xdg / "opencode" / "opencode.json") monkeypatch.setattr( opencode, "OPENCODE_BACKUP_PATH", tmp_path / "opencode-config.backup.json" )