From 9a3f058f730c9ef28159e0a3c90aaa6637668b4c Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Thu, 3 Sep 2026 10:56:37 -0700 Subject: [PATCH] Fix OpenCode defaults and false health failures --- .../opencode/templates/opencode/agent.html | 72 ----------------- archivebox/opencode/urls.py | 3 +- archivebox/opencode/views.py | 58 +++++--------- archivebox/tests/test_opencode_agent.py | 79 +++++++++++++------ 4 files changed, 75 insertions(+), 137 deletions(-) diff --git a/archivebox/opencode/templates/opencode/agent.html b/archivebox/opencode/templates/opencode/agent.html index e6f41a67..8a252295 100644 --- a/archivebox/opencode/templates/opencode/agent.html +++ b/archivebox/opencode/templates/opencode/agent.html @@ -206,78 +206,6 @@ }); })(); - {% endif %} {% endblock %} diff --git a/archivebox/opencode/urls.py b/archivebox/opencode/urls.py index bf8fad73..1b073502 100644 --- a/archivebox/opencode/urls.py +++ b/archivebox/opencode/urls.py @@ -1,11 +1,10 @@ from django.urls import path, re_path -from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view +from archivebox.opencode.views import agent_view, opencode_proxy_view urlpatterns = [ path("", agent_view, name="opencode-agent"), - path("opencode/_archivebox/health", agent_health_view, name="opencode-health"), re_path( r"^opencode(?:/(?P.*))?$", opencode_proxy_view, diff --git a/archivebox/opencode/views.py b/archivebox/opencode/views.py index 49ba0de1..f2913195 100644 --- a/archivebox/opencode/views.py +++ b/archivebox/opencode/views.py @@ -2,6 +2,7 @@ from __future__ import annotations import atexit import base64 +import json import logging import os import re @@ -26,7 +27,6 @@ from django.http import ( HttpRequest, HttpResponse, HttpResponseForbidden, - JsonResponse, StreamingHttpResponse, ) from django.shortcuts import redirect, render @@ -42,6 +42,7 @@ _PROXY_PREFIX = "/admin/agent/opencode" _PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/") _PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/") _CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json") +_DEFAULT_MODEL = "opencode/big-pickle" _TEXT_CONTENT_TYPES = ( "text/", @@ -219,7 +220,7 @@ def _settings(config: dict) -> dict: str(_config_value(config, "OPENCODE_STATE_DIR", default_data_dir / "opencode")), ).expanduser() workdir = Path( - str(_config_value(config, "OPENCODE_WORKDIR", opencode_dir / "workdir")), + str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)), ).expanduser() binary = str(_config_value(config, "OPENCODE_BINARY", "opencode")) timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 120)) @@ -321,6 +322,21 @@ def _ensure_project_files(settings: dict) -> None: opencode_skill_path.unlink() opencode_skill_path.symlink_to(editable_skill_path) + opencode_config_path = settings["config_home"] / "opencode" / "opencode.jsonc" + default_config = { + "$schema": "https://opencode.ai/config.json", + "model": _DEFAULT_MODEL, + } + if not opencode_config_path.exists(): + opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n") + else: + try: + existing_config = json.loads(opencode_config_path.read_text()) + except (OSError, ValueError): + existing_config = None + if isinstance(existing_config, dict) and set(existing_config) <= {"$schema"}: + opencode_config_path.write_text(f"{json.dumps(default_config, indent=2)}\n") + def _ensure_default_session(settings: dict) -> str: workdir = settings["workdir"].resolve() @@ -393,19 +409,6 @@ def _health(settings: dict, timeout: float = 2) -> bool: return False -def _opencode_version(settings: dict) -> str: - try: - response = requests.get( - f"{settings['origin']}/global/health", - timeout=2, - ) - response.raise_for_status() - data = response.json() - return str(data.get("version") or "") if isinstance(data, dict) else "" - except (requests.RequestException, ValueError): - return "" - - def _ensure_opencode(settings: dict) -> tuple[bool, str]: global _PROCESS, _PROCESS_READY started_process: subprocess.Popen | None = None @@ -539,16 +542,13 @@ def agent_view(request: HttpRequest): settings["archivebox_api_url"] = api_url ok, error = _ensure_opencode(settings) recent_session_id = "" - opencode_version = "" if ok: try: with _SESSION_LOCK: recent_session_id = _ensure_default_session(settings) - opencode_version = _opencode_version(settings) - if not opencode_version: - ok = False - error = "OpenCode health check did not report its version." except (requests.RequestException, RuntimeError, ValueError) as err: + if isinstance(err, requests.RequestException) and _owned_process_ready(): + _stop_owned_process() ok = False error = f"OpenCode project initialization failed: {err}" from archivebox.core.admin_site import archivebox_admin @@ -565,7 +565,6 @@ def agent_view(request: HttpRequest): # transient new-session route and appear to have lost prior sessions. "proxy_url": _project_route(settings["workdir"], recent_session_id), "proxy_prefix": _PROXY_PREFIX, - "opencode_version": opencode_version, "workdir": str(settings["workdir"].resolve()), "recent_session_id": recent_session_id, } @@ -577,23 +576,6 @@ def agent_view(request: HttpRequest): ) -def agent_health_view(request: HttpRequest): - config = _machine_config() - _require_enabled(config) - auth_response = _require_superuser(request) - if auth_response: - return auth_response - - version = _opencode_version(_settings(config)) - healthy = bool(version) - response = JsonResponse( - {"healthy": healthy, "version": version}, - status=200 if healthy else 503, - ) - response.headers["Cache-Control"] = "no-store" - return response - - def _proxy_url(settings: dict, path: str | None) -> str: rel = "/" if not path else f"/{path}" return urljoin(settings["origin"], rel) diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py index 0d81c15d..ff639958 100644 --- a/archivebox/tests/test_opencode_agent.py +++ b/archivebox/tests/test_opencode_agent.py @@ -1,4 +1,5 @@ import asyncio +import json import os import signal import socket @@ -189,14 +190,8 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode assert b'id="header"' in response.content assert b'id="progress-monitor"' in response.content assert response.context["proxy_prefix"] == views._PROXY_PREFIX - assert response.context["opencode_version"] - assert b"const healthUrl" in response.content - assert b"/admin/agent/opencode/global/health" in response.content - assert b"/admin/agent/opencode/_archivebox/health" in response.content - assert b'redirect: "manual"' in response.content - assert b"let waking = false" in response.content - assert b"wake();" in response.content - assert b"frame.contentWindow.location.reload()" in response.content + assert b"/_archivebox/health" not in response.content + assert b"window.setInterval(check, 3000)" not in response.content assert response.headers["X-Frame-Options"] == "DENY" assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'" @@ -210,21 +205,6 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'" -def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode): - from archivebox.opencode import views - - views._stop_owned_process() - response = admin_client.get( - "/admin/agent/opencode/_archivebox/health", - HTTP_HOST=ADMIN_TEST_HOST, - ) - - assert response.status_code == 503 - assert response.json() == {"healthy": False, "version": ""} - assert response.headers["Cache-Control"] == "no-store" - assert views._PROCESS is None - - def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode): workdir = str(live_opencode.config.data_dir.resolve()) encoded_workdir = quote(workdir) @@ -422,8 +402,15 @@ def test_opencode_starts_with_isolated_state(live_opencode): ) project.raise_for_status() + config = requests.get( + f"{live_opencode.settings['origin']}/global/config", + timeout=live_opencode.settings["timeout"], + ) + config.raise_for_status() + assert Path(live_opencode.settings["workdir"]).resolve() == Path(workdir) assert Path(str(project.json()["worktree"])).resolve() == Path(workdir) + assert config.json()["model"] == "opencode/big-pickle" assert live_opencode.process.poll() is None assert (live_opencode.config.data_dir / ".git").is_dir() assert (state_dir / "data" / "opencode" / "opencode.db").is_file() @@ -455,15 +442,57 @@ def test_opencode_state_dir_is_separate_from_workdir(tmp_path): assert loaded_skill.is_symlink() assert loaded_skill.resolve() == editable_skill.resolve() assert f"ArchiveBox collection directory: {settings['archivebox_data_dir']}" in editable_skill.read_text() + assert json.loads((state_dir / "config" / "opencode" / "opencode.jsonc").read_text())["model"] == "opencode/big-pickle" -def test_opencode_default_workdir_does_not_scan_the_collection(): +def test_opencode_preserves_existing_config(tmp_path): + from archivebox.opencode import views + + state_dir = tmp_path / "state" + config_path = state_dir / "config" / "opencode" / "opencode.jsonc" + config_path.parent.mkdir(parents=True) + existing_config = '{\n // Keep the administrator-selected model.\n "model": "anthropic/claude-sonnet-4-5"\n}\n' + config_path.write_text(existing_config) + + views._ensure_project_files( + views._settings( + { + "OPENCODE_WORKDIR": str(tmp_path / "workdir"), + "OPENCODE_STATE_DIR": str(state_dir), + }, + ), + ) + + assert config_path.read_text() == existing_config + + +def test_opencode_adds_default_model_to_schema_only_config(tmp_path): + from archivebox.opencode import views + + state_dir = tmp_path / "state" + config_path = state_dir / "config" / "opencode" / "opencode.jsonc" + config_path.parent.mkdir(parents=True) + config_path.write_text('{"$schema": "https://opencode.ai/config.json"}\n') + + views._ensure_project_files( + views._settings( + { + "OPENCODE_WORKDIR": str(tmp_path / "workdir"), + "OPENCODE_STATE_DIR": str(state_dir), + }, + ), + ) + + assert json.loads(config_path.read_text())["model"] == "opencode/big-pickle" + + +def test_opencode_defaults_to_the_archivebox_collection(): from archivebox.opencode import views settings = views._settings({}) assert settings["opencode_dir"] == settings["archivebox_data_dir"] / "opencode" - assert settings["workdir"] == settings["opencode_dir"] / "workdir" + assert settings["workdir"] == settings["archivebox_data_dir"] assert settings["timeout"] == 120