import asyncio import os import re import shutil import socket from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import SimpleNamespace from urllib.parse import parse_qs, quote, urlsplit import pytest import requests from asgiref.testing import ApplicationCommunicator from archivebox.tests.conftest import ADMIN_TEST_HOST, run_archivebox_cmd from archivebox.config.common import get_config pytestmark = pytest.mark.django_db(transaction=True) def _free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def _reset_runtime_config() -> None: from archivebox.config import common from archivebox.machine.models import Machine for value in vars(common).values(): cache_clear = getattr(value, "cache_clear", None) if cache_clear is not None: cache_clear() Machine.current(refresh=True) def _set_archivebox_config(data_dir: Path, *values: str, env: dict[str, str] | None = None) -> None: os.chdir(data_dir) result = run_archivebox_cmd( ["config", "--set", *values], cwd=data_dir, env=env, timeout=120, ) assert result.returncode == 0, result.stderr or result.stdout _reset_runtime_config() @pytest.fixture def opencode_archive_config(initialized_archive): port = _free_port() state_dir = initialized_archive / "opencode" env = os.environ.copy() env.update( { "ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS": "true", "OPENCODE_ENABLED": "True", "OPENCODE_HOST": "127.0.0.1", "OPENCODE_PORT": str(port), "OPENCODE_WORKDIR": str(initialized_archive), "OPENCODE_STATE_DIR": str(state_dir), "OPENCODE_TIMEOUT": "60", }, ) _set_archivebox_config( initialized_archive, "OPENCODE_ENABLED=True", "OPENCODE_HOST=127.0.0.1", f"OPENCODE_PORT={port}", f"OPENCODE_WORKDIR={initialized_archive}", f"OPENCODE_STATE_DIR={state_dir}", "OPENCODE_TIMEOUT=60", env=env, ) return SimpleNamespace(data_dir=initialized_archive, port=port, state_dir=state_dir, env=env) @pytest.fixture def installed_opencode(opencode_archive_config): from abx_plugins.plugins.opencode import runtime install = run_archivebox_cmd( ["install", "opencode", "--binproviders=env,pnpm"], cwd=opencode_archive_config.data_dir, env=opencode_archive_config.env, timeout=1200, ) assert install.returncode == 0, install.stderr or install.stdout _reset_runtime_config() config = get_config().model_dump(mode="json") settings = runtime._settings(config, opencode_archive_config.data_dir) settings["archivebox_base_url"] = "http://admin.archivebox.localhost:8000" settings["archivebox_admin_url"] = "http://admin.archivebox.localhost:8000/admin" settings["archivebox_api_url"] = "http://admin.archivebox.localhost:8000/api/" binary, binary_env = runtime._resolve_binary(settings["binary"], settings["config"]) version = binary.exec( cmd=("--version",), env={**os.environ, **binary_env}, timeout=120, ) assert version.returncode == 0, version.stderr or version.stdout return SimpleNamespace(config=opencode_archive_config, settings=settings) @pytest.fixture def live_opencode(installed_opencode): from abx_plugins.plugins.opencode import runtime settings = installed_opencode.settings ok, error = runtime._ensure_opencode(settings) assert ok, error process = runtime._PROCESS assert process is not None try: yield SimpleNamespace(config=installed_opencode.config, settings=settings, process=process) finally: runtime._stop_owned_process() def test_opencode_disabled_route_does_not_start_server(client, initialized_archive): from archivebox.machine.models import Machine from abx_plugins.plugins.opencode import runtime os.chdir(initialized_archive) Machine.from_json({"config": {"OPENCODE_ENABLED": False}}) _reset_runtime_config() assert get_config().model_dump(mode="json")["OPENCODE_ENABLED"] is False response = client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 404 assert runtime._PROCESS is None or runtime._PROCESS.poll() is not None def test_opencode_disabled_via_cli_stays_disabled(admin_client, initialized_archive): _set_archivebox_config(initialized_archive, "OPENCODE_ENABLED=False") assert get_config().OPENCODE_ENABLED is False assert admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST).status_code == 404 for path in ("/add/", "/admin/core/snapshot/"): response = admin_client.get(path, HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 200 assert b'href="/admin/agent"' not in response.content def test_opencode_agent_requires_superuser_when_enabled(client, db, django_user_model, live_opencode): response = client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 302 assert "/admin/login/" in response.headers["Location"] next_path = "/admin/agent?x=1&next=https://example.com" response = client.get(next_path, HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 302 assert parse_qs(urlsplit(response.headers["Location"]).query) == {"next": [next_path]} user = django_user_model.objects.create_user(username="regular", password="testpassword") client.force_login(user) response = client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 403 def test_opencode_proxy_blocks_cross_origin_mutation(admin_client, db, live_opencode): response = admin_client.post( "/admin/agent/opencode/session", data=b"{}", content_type="application/json", HTTP_HOST=ADMIN_TEST_HOST, HTTP_ORIGIN="https://evil.example", ) assert response.status_code == 403 def test_opencode_proxy_blocks_cross_site_fetch_metadata(admin_client, db, live_opencode): response = admin_client.post( "/admin/agent/opencode/session", data=b"{}", content_type="application/json", HTTP_HOST=ADMIN_TEST_HOST, HTTP_SEC_FETCH_SITE="cross-site", ) assert response.status_code == 403 def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode): from abx_plugins.plugins.opencode import runtime response = admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) recent_session_id = response.context["recent_session_id"] session_path = runtime._project_route(live_opencode.config.data_dir, recent_session_id) assert response.status_code == 200 assert recent_session_id assert f'