import asyncio import json import os import signal import socket import subprocess from concurrent.futures import ThreadPoolExecutor from pathlib import Path from types import SimpleNamespace from urllib.parse import quote import pytest import requests from asgiref.testing import ApplicationCommunicator from archivebox.tests.conftest import ADMIN_TEST_HOST, run_archivebox_cmd 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 live_opencode(opencode_archive_config): from archivebox.opencode import views 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 = views._machine_config() settings = views._settings(config) 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 = views._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 ok, error = views._ensure_opencode(settings) assert ok, error process = views._PROCESS assert process is not None try: yield SimpleNamespace(config=opencode_archive_config, settings=settings, process=process) finally: views._stop_owned_process() def test_opencode_disabled_route_does_not_start_server(client, initialized_archive): from archivebox.machine.models import Machine from archivebox.opencode import views os.chdir(initialized_archive) Machine.from_json({"config": {"OPENCODE_ENABLED": False}}) _reset_runtime_config() assert views._machine_config()["OPENCODE_ENABLED"] is False response = client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 404 assert views._PROCESS is None or views._PROCESS.poll() is not None def test_stop_owned_process_falls_back_for_stopped_process_without_dedicated_group(): from archivebox.opencode import views process = subprocess.Popen(["sleep", "60"]) try: process.send_signal(signal.SIGSTOP) views._stop_owned_process(process) assert process.returncode == -signal.SIGTERM finally: if process.poll() is None: process.kill() process.wait() 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"] 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 archivebox.opencode import views response = admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST) recent_session_id = response.context["recent_session_id"] session_path = views._project_route(live_opencode.config.data_dir, recent_session_id) assert response.status_code == 200 assert recent_session_id assert f'