Isolate OpenCode in its plugin and stop forced Git initialization (#1869)

* Stop initializing Git repositories for OpenCode sessions

* Isolate optional AI failures behind a lazy plugin adapter

* Keep explicit plugin enable flags boolean after config resolution

* Use Django login redirect encoding at the optional agent boundary

* Cover cold-start failures and real browser storage isolation

* Verify persisted dismissal and native storage denial variants

* Keep headless browser interaction independent of display timing

* Install optional OpenCode clients through the plugin extra
This commit is contained in:
Nick Sweeting 2026-09-03 16:05:30 -07:00 committed by GitHub
parent b960bfb93f
commit d044f5ee0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 419 additions and 1132 deletions

View File

@ -18,7 +18,7 @@ from urllib.parse import quote, urlparse
from abx_plugins.plugins.base.utils import build_config_model
from django.db import DatabaseError
from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter, create_model, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console
@ -1253,7 +1253,7 @@ def get_config(
config = config.model_copy(update={"SERVER_SECURITY_MODE": "safe-subdomains-fullreplay"})
for key in explicit_plugin_enabled_keys:
if key in config_data:
setattr(config, key, config_data[key])
setattr(config, key, TypeAdapter(bool).validate_python(config_data[key]))
if config.PLUGINS:
config._derive_plugin_enabled_config(respect_current_enabled=True)
if redact_sensitive:

View File

@ -89,13 +89,7 @@ def AdminCookieIsolationMiddleware(get_response):
response = get_response(request)
if request.path == "/admin" or request.path.startswith("/admin/"):
from archivebox.opencode.views import _PROXY_PREFIX
is_opencode_proxy = request.path == _PROXY_PREFIX or request.path.startswith(f"{_PROXY_PREFIX}/")
if is_opencode_proxy:
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
else:
if not getattr(response, "xframe_options_exempt", False):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"

View File

@ -76,7 +76,6 @@ INSTALLED_APPS = [
"archivebox.crawls", # handles Crawl and CrawlSchedule models and management (depends on core)
"archivebox.progressmonitor", # live progress endpoint and admin monitor template
"archivebox.api", # Django-Ninja-based Rest API interfaces, config, APIToken model, etc.
"archivebox.opencode",
# 3rd-party apps from PyPI that need to be loaded last
"admin_data_views", # handles rendering some convenient automatic read-only views of data in Django admin
"django_extensions", # provides Django Debug Toolbar (and other non-debug helpers)

View File

@ -1,3 +1,4 @@
import logging
import os
from html import unescape
from pathlib import Path
@ -28,6 +29,20 @@ from archivebox.plugins.discovery import (
register = template.Library()
@register.simple_tag(takes_context=True)
def plugin_ui(context, plugin: str, slot: str):
"""Optional plugin UI must never prevent the host page from rendering."""
try:
source = get_plugin_template(plugin, slot, fallback=False)
if not source:
return ""
return template.engines["django"].from_string(source).render(context.flatten())
except Exception:
logging.getLogger(__name__).exception("Unable to render optional plugin UI: %s/%s", plugin, slot)
return ""
_TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log")
_IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif")
_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class OpencodeConfig(AppConfig):
name = "archivebox.opencode"
label = "opencode_plugin"

View File

@ -1,211 +0,0 @@
{% extends "admin/base.html" %}
{% load i18n %}
{% block title %}Agent{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; Agent
</div>
{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
body.opencode-agent #content {
padding: 0;
}
body.opencode-agent #content-main {
height: calc(100vh - 150px);
min-height: 560px;
}
.opencode-agent-error {
margin: 24px;
color: #0f172a;
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.opencode-agent-error code {
color: #0369a1;
}
.opencode-agent-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.opencode-agent-shell {
position: relative;
height: 100%;
}
.opencode-agent-welcome {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.48);
color: #111827;
}
.opencode-agent-welcome[hidden] {
display: none;
}
.opencode-agent-welcome-panel {
width: min(720px, 100%);
max-height: min(720px, calc(100vh - 190px));
overflow: auto;
border-radius: 8px;
border: 1px solid #d1d5db;
background: Canvas;
color: CanvasText;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
padding: 24px;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.opencode-agent-welcome-panel h1 {
margin: 0 0 10px;
font-size: 22px;
line-height: 1.25;
}
.opencode-agent-welcome-panel p {
margin: 0 0 14px;
}
.opencode-agent-welcome-panel ul {
margin: 0 0 16px 20px;
padding: 0;
}
.opencode-agent-welcome-panel li {
margin: 0 0 8px;
}
.opencode-agent-welcome-warning {
margin: 16px 0;
padding: 12px 14px;
border: 1px solid #f59e0b;
border-radius: 6px;
background: color-mix(in srgb, #f59e0b 12%, Canvas);
}
.opencode-agent-welcome-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.opencode-agent-welcome button {
cursor: pointer;
border: 1px solid #2563eb;
border-radius: 6px;
background: #2563eb;
color: #fff;
padding: 8px 14px;
font-weight: 600;
}
@media (max-width: 700px) {
.opencode-agent-welcome {
padding: 12px;
}
.opencode-agent-welcome-panel {
max-height: calc(100vh - 170px);
padding: 18px;
}
}
</style>
{% endblock %}
{% block bodyclass %}{{ block.super }} opencode-agent{% endblock %}
{% block content %}
<div id="content-main">
{% if error %}
<div class="opencode-agent-error">
{{ error }}
{% if command %}<br><br><code>{{ command }}</code>{% endif %}
</div>
{% else %}
<script>
(() => {
const workdir = "{{ workdir|escapejs }}";
const recentSessionId = "{{ recent_session_id|escapejs }}";
const merge = (left, right) => {
if (!left || typeof left !== "object" || Array.isArray(left)) return right;
const out = {...left};
for (const [key, value] of Object.entries(right)) {
out[key] = value && typeof value === "object" && !Array.isArray(value) ? merge(out[key], value) : value;
}
return out;
};
const seed = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(merge(JSON.parse(localStorage.getItem(key) || "{}"), value)));
} catch {
localStorage.setItem(key, JSON.stringify(value));
}
};
const setJSON = (key, value) => localStorage.setItem(key, JSON.stringify(value));
const page = {
activeProject: workdir,
activeWorkspace: workdir,
gettingStartedDismissed: true,
workspaceExpanded: {[workdir]: true},
};
if (recentSessionId) {
page.lastProjectSession = {[workdir]: {directory: workdir, id: recentSessionId, at: Date.now()}};
}
seed("opencode.global.dat:layout.page", page);
seed("opencode.global.dat:layout", {
sidebar: {opened: true, width: 280},
fileTree: {opened: true, width: 280, tab: "all"},
});
const serverKey = `${location.origin}/admin/agent/opencode`;
setJSON("opencode.global.dat:server", {
list: [],
projects: {
[serverKey]: [{worktree: workdir, expanded: true}],
},
lastProject: {
[serverKey]: workdir,
},
});
localStorage.setItem("opencode.settings.dat:defaultServerUrl", serverKey);
})();
</script>
<div class="opencode-agent-shell">
<iframe src="{{ proxy_url }}" title="OpenCode Agent" class="opencode-agent-frame"></iframe>
<div class="opencode-agent-welcome" id="opencode-agent-welcome" hidden>
<div class="opencode-agent-welcome-panel" role="dialog" aria-modal="true" aria-labelledby="opencode-agent-welcome-title">
<h1 id="opencode-agent-welcome-title">ArchiveBox AI Agent</h1>
<p>This agent can work directly with your ArchiveBox collection. It can inspect the crawl database, create and monitor crawls, run maintenance operations, answer research questions, query archived data, and help with complex collection workflows.</p>
<p>Example prompts:</p>
<ul>
<li>Archive this list of URLs with depth 0, then report which ones failed and why.</li>
<li>Find snapshots from the last month that failed PDF or screenshot extraction and retry them.</li>
<li>Create a constrained crawl for this site, avoid login/privacy/sitemap URLs, and watch the logs as it runs.</li>
<li>Summarize what my collection contains about this topic and link to the best saved pages.</li>
</ul>
<div class="opencode-agent-welcome-warning">
The agent has unrestricted access to this collection and can make destructive edits. Review requests carefully. Permission settings can be changed from the OpenCode gear icon in the lower left.
</div>
<div class="opencode-agent-welcome-actions">
<button type="button" id="opencode-agent-welcome-dismiss">Start using Agent</button>
</div>
</div>
</div>
</div>
<script>
(() => {
const key = "archivebox.opencode.agentWelcomeDismissed.v1";
const welcome = document.getElementById("opencode-agent-welcome");
const dismiss = document.getElementById("opencode-agent-welcome-dismiss");
if (!welcome || !dismiss) return;
if (localStorage.getItem(key) !== "1") {
welcome.hidden = false;
}
dismiss.addEventListener("click", () => {
localStorage.setItem(key, "1");
welcome.hidden = true;
});
})();
</script>
{% endif %}
</div>
{% endblock %}

View File

@ -1,776 +1,76 @@
from __future__ import annotations
"""Django adapter for the optional plugin; never import its runtime at startup."""
import atexit
import base64
import logging
import os
import re
import signal
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlsplit
import httpx
import requests
from abx_plugins.plugins import opencode as opencode_plugin
from django.contrib.auth.views import redirect_to_login
from django.http import Http404, HttpResponse, HttpResponseForbidden, StreamingHttpResponse
from django.template import engines
from django.views.decorators.csrf import csrf_exempt
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.core.routes_util import build_admin_url, get_api_base_url, get_base_url
from asgiref.sync import sync_to_async
from django.http import (
Http404,
HttpRequest,
HttpResponse,
HttpResponseForbidden,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
from django.views.decorators.csrf import csrf_exempt
from archivebox.plugins.discovery import get_plugin_template
_PROCESS: subprocess.Popen | None = None
_PROCESS_READY: subprocess.Popen | None = None
_PROCESS_LOCK = threading.Lock()
_SESSION_LOCK = threading.Lock()
_LOGGER = logging.getLogger(__name__)
_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"
_DEFAULT_CONFIG = f'''{{
"$schema": "https://opencode.ai/config.json",
"model": "{_DEFAULT_MODEL}",
"snapshot": false
}}
'''
_TEXT_CONTENT_TYPES = (
"text/",
"application/javascript",
"application/x-javascript",
)
_HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
_ARCHIVEBOX_SKILL = """---
name: archivebox
description: Use ArchiveBox's CLI and local REST API from an ArchiveBox collection.
---
You are running inside an ArchiveBox collection directory.
- ArchiveBox collection directory: {archivebox_data_dir}
- ArchiveBox BASE_URL: {archivebox_base_url}
- ArchiveBox Admin URL: {archivebox_admin_url}
- ArchiveBox REST API URL: {archivebox_api_url}
- Prefer the `archivebox` CLI for authenticated changes, e.g. `archivebox add`, `archivebox schedule`, `archivebox update`, and `archivebox shell`.
- Run ArchiveBox CLI commands from the ArchiveBox collection directory above.
- Get command help with `archivebox list --help`, `archivebox add --help`, `archivebox schedule --help`, etc. Do not use `archivebox help <command>`.
- Use `--depth=0` by default. Only use recursive crawling when the user explicitly asks for it; use `--depth=1` when you need pages one hop out.
- Before any recursive crawl, constrain scope with ArchiveBox config such as `CRAWL_MAX_URLS`, `CRAWL_MAX_SIZE`, `SNAPSHOT_MAX_*`, `URL_ALLOWLIST`, `URL_DENYLIST`, and related limits.
- Respect the configured `archivebox config --get ONLY_NEW` behavior unless the user explicitly says otherwise. Remind users that expected crawl URLs can be skipped when the collection already contains snapshots with the same URL.
- Always audit newly discovered crawl URLs before letting a crawl run broadly. Treat junk URLs such as privacy policies, legal pages, tag archives, sitemap files, feeds, login/logout URLs, and other low-value boilerplate as unwanted unless the user explicitly asked to archive them.
- Always watch crawl output and logs as the crawl progresses, and correct errors early instead of waiting until the crawl finishes.
- If a crawl contains bad URLs, pause it, edit the crawl's `urls` field to remove them, delete any unneeded snapshots already created under that crawl, then resume the crawl.
- Use `archivebox shell -c '...'` or `archivebox shell <<'PY' ... PY` for Django ORM work. Shell Plus prints an import banner first; keep stderr visible while debugging.
- Use full ArchiveBox module paths in shell code: `from archivebox.crawls.models import Crawl, CrawlSchedule` and `from archivebox.core.models import Snapshot, ArchiveResult`.
- If a model/field/relation is unclear, inspect `_meta.fields` before guessing, e.g. `archivebox shell -c "from archivebox.crawls.models import Crawl; print([f.name for f in Crawl._meta.fields])"`.
- Use `archivebox config --get BASE_URL` only to verify the configured base URL; prefer the seeded URLs above for API/admin requests.
- Use `$ARCHIVEBOX_API_URL` for REST API inspection when helpful. Do not assume admin session cookies authenticate API subdomain requests; prefer CLI/shell for authenticated mutations unless the admin provides or asks you to create an API token.
- Discover REST endpoints from `${{ARCHIVEBOX_API_URL}}v1/openapi.json`; crawl endpoints live under `/api/v1/crawls/`, snapshots under `/api/v1/core/`.
- Do not bypass ArchiveBox auth, expose API keys, or modify config unless the admin explicitly asks.
- After creating crawls or snapshots, report the crawl/snapshot IDs and the exact command or API request used.
"""
def _signal_owned_process(process: subprocess.Popen, sig: signal.Signals) -> None:
def _dispatch(request, path=None):
try:
os.killpg(process.pid, sig)
except OSError:
try:
process.send_signal(sig)
except ProcessLookupError:
pass
config = dict(get_config().model_dump(mode="json"))
if not config.get("OPENCODE_ENABLED", False):
raise Http404
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path(), login_url="/admin/login/")
if not request.user.is_active or not request.user.is_superuser:
return HttpResponseForbidden("Agent access requires a superuser account.")
from abx_plugins.plugins.opencode import runtime
def _stop_owned_process(process: subprocess.Popen | None = None) -> None:
global _PROCESS, _PROCESS_READY
owned_process = process or _PROCESS
if owned_process is None:
return
if owned_process.poll() is None:
_signal_owned_process(owned_process, signal.SIGCONT)
_signal_owned_process(owned_process, signal.SIGTERM)
try:
owned_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_signal_owned_process(owned_process, signal.SIGKILL)
owned_process.wait()
if _PROCESS is owned_process:
_PROCESS = None
if _PROCESS_READY is owned_process:
_PROCESS_READY = None
atexit.register(_stop_owned_process)
def _machine_config() -> dict[str, Any]:
resolved = get_config()
return dict(resolved.model_dump(mode="json"))
def _archivebox_data_dir_default() -> Path:
return Path(CONSTANTS.DATA_DIR)
def _archivebox_route_urls(request: HttpRequest, route_config) -> tuple[str, str, str]:
base_url = get_base_url(
request=request,
config=route_config,
).rstrip("/")
admin_url = build_admin_url(
"/admin/",
request=request,
config=route_config,
).rstrip("/")
api_url = f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/"
return base_url, admin_url, api_url
def _config_value(config: dict, key: str, default):
value = config.get(key, default)
if value in (None, ""):
return default
return value
def _opencode_enabled(config: dict) -> bool:
value = config.get("OPENCODE_ENABLED", False)
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _require_enabled(config: dict) -> None:
if not _opencode_enabled(config):
raise Http404
def _require_superuser(request: HttpRequest):
user = getattr(request, "user", None)
if user is None:
return redirect(f"/admin/login/?next={request.get_full_path()}")
if (
bool(getattr(user, "is_authenticated", False))
and bool(getattr(user, "is_active", False))
and bool(getattr(user, "is_superuser", False))
):
return None
if bool(getattr(user, "is_authenticated", False)):
return HttpResponseForbidden(
b"ArchiveBox agent access requires a superuser account.",
settings = runtime._settings(config, CONSTANTS.DATA_DIR)
route_config = request.__dict__.get("archivebox_config")
settings.update(
archivebox_base_url=get_base_url(request=request, config=route_config).rstrip("/"),
archivebox_admin_url=build_admin_url("/admin/", request=request, config=route_config).rstrip("/"),
archivebox_api_url=f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/",
)
return redirect(f"/admin/login/?next={request.get_full_path()}")
if path is None:
from archivebox.core.admin_site import archivebox_admin
context = {**archivebox_admin.each_context(request), **runtime.agent_context(settings)}
source = get_plugin_template("opencode", "agent", fallback=False)
if source is None:
raise RuntimeError("Agent template unavailable")
return HttpResponse(engines["django"].from_string(source).render(context, request))
def _origin_allowed(request: HttpRequest, path: str | None = None) -> bool:
if request.method in {"GET", "HEAD", "OPTIONS", "TRACE"}:
return True
expected_host = request.get_host()
pty_connect = bool(
path and path.startswith("pty/") and path.endswith("/connect-token"),
)
if pty_connect:
return True
origin = _request_header(request, "Origin")
if origin:
return _same_host(origin, expected_host)
referer = _request_header(request, "Referer")
if referer:
return _same_host(referer, expected_host)
fetch_site = _request_header(request, "Sec-Fetch-Site")
if fetch_site:
return fetch_site in {"same-origin", "same-site", "none"}
return False
def _same_host(value: str, expected_host: str) -> bool:
parsed = urlsplit(value)
return parsed.scheme in {"http", "https"} and parsed.netloc == expected_host
def _settings(config: dict) -> dict:
host = str(_config_value(config, "OPENCODE_HOST", "127.0.0.1"))
port = int(_config_value(config, "OPENCODE_PORT", 4096))
default_data_dir = _archivebox_data_dir_default()
opencode_dir = Path(
str(_config_value(config, "OPENCODE_STATE_DIR", default_data_dir / "opencode")),
).expanduser()
workdir = Path(
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))
return {
"host": host,
"port": port,
"origin": f"http://{host}:{port}",
"archivebox_data_dir": default_data_dir,
"workdir": workdir,
"opencode_dir": opencode_dir,
"config_home": opencode_dir / "config",
"data_home": opencode_dir / "data",
"state_home": opencode_dir / "state",
"cache_home": opencode_dir / "cache",
"home": opencode_dir / "home",
"binary": binary,
"config": config,
"timeout": timeout,
}
def _resolve_binary(binary: str, config: dict) -> tuple[Any, Any, dict[str, str]]:
try:
from abxpkg import BinProvider
from abx_plugins.plugins.base.utils import load_required_binary_from_config
binary_environ = os.environ.copy()
lib_dir = config.get("ABXPKG_LIB_DIR")
if lib_dir:
binary_environ["ABXPKG_LIB_DIR"] = str(lib_dir)
loaded_dependencies = [
load_required_binary_from_config(
required_binary,
_CONFIG_PATH,
global_config=config,
environ=binary_environ,
install=False,
)
for required_binary in (
str(config.get("NODE_BINARY") or "node"),
str(config.get("GIT_BINARY") or "git"),
binary,
)
]
except Exception as err:
raise RuntimeError(
f"OpenCode dependency is not installed from required_binaries: {err}",
) from err
if any(not loaded.loaded_abspath for loaded in loaded_dependencies):
raise RuntimeError(
"OpenCode dependency is not installed from required_binaries.",
if not runtime._origin_allowed(request.method, request.get_host(), request.headers):
return HttpResponseForbidden("Cross-origin agent requests are blocked.")
status, headers, body = runtime.proxy(
settings,
request.method,
path,
tuple((key, value) for key, values in request.GET.lists() for value in values),
request.headers,
request.body,
)
providers = [loaded.loaded_binprovider for loaded in loaded_dependencies if loaded.loaded_binprovider is not None]
binary_env = BinProvider.build_exec_env(
providers=providers,
base_env=binary_environ,
)
return (
loaded_dependencies[-1],
loaded_dependencies[1],
binary_env,
)
response_type = HttpResponse if isinstance(body, bytes) else StreamingHttpResponse
response = response_type(body, status=status, headers=headers)
response.xframe_options_exempt = True
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
except Http404:
raise
except Exception:
# Optional-service boundary, including imports and template rendering.
_LOGGER.exception("Optional AI service failed")
return HttpResponse("AI service unavailable. See server logs.", status=503, content_type="text/plain")
def _project_route(workdir: Path, session_id: str = "") -> str:
encoded = base64.b64encode(str(workdir.resolve()).encode()).decode()
encoded = encoded.replace("+", "-").replace("/", "_").rstrip("=")
route = f"{_PROXY_PREFIX}/{encoded}/session"
return f"{route}/{session_id}" if session_id else route
def _ensure_project_files(settings: dict) -> None:
workdir = settings["workdir"].resolve()
workdir.mkdir(parents=True, exist_ok=True)
git_marker = workdir / ".git" / "not-a-git"
if git_marker.exists():
# Current OpenCode hangs on the legacy fake marker, so remove only
# that invalid shape before initializing the real worktree.
shutil.rmtree(git_marker.parent)
editable_skill_path = settings["opencode_dir"] / "SKILL.md"
editable_skill_path.parent.mkdir(parents=True, exist_ok=True)
if not editable_skill_path.exists():
editable_skill_path.write_text(
_ARCHIVEBOX_SKILL.format(
archivebox_data_dir=settings["archivebox_data_dir"],
archivebox_base_url=settings.get("archivebox_base_url", ""),
archivebox_admin_url=settings.get("archivebox_admin_url", ""),
archivebox_api_url=settings.get("archivebox_api_url", ""),
),
)
opencode_skill_path = settings["config_home"] / "opencode" / "skills" / "archivebox" / "SKILL.md"
opencode_skill_path.parent.mkdir(parents=True, exist_ok=True)
if opencode_skill_path.resolve() != editable_skill_path.resolve():
if opencode_skill_path.exists() or opencode_skill_path.is_symlink():
opencode_skill_path.unlink()
opencode_skill_path.symlink_to(editable_skill_path)
opencode_config_path = settings["config_home"] / "opencode" / "opencode.jsonc"
if not opencode_config_path.exists():
opencode_config_path.write_text(_DEFAULT_CONFIG)
def _ensure_default_session(settings: dict) -> str:
workdir = settings["workdir"].resolve()
params = {"directory": str(workdir)}
timeout = settings["timeout"]
project = requests.post(
f"{settings['origin']}/project/git/init",
params=params,
timeout=timeout,
)
project.raise_for_status()
project_data = project.json()
if Path(str(project_data.get("worktree") or "/")).resolve() != workdir:
raise RuntimeError(
f"OpenCode initialized the wrong project worktree: {project_data.get('worktree')!r}",
)
sessions = requests.get(
f"{settings['origin']}/session",
params={**params, "roots": "true", "limit": 55},
timeout=timeout,
)
sessions.raise_for_status()
session_data = sessions.json()
if not isinstance(session_data, list):
raise RuntimeError("OpenCode returned an invalid project session list.")
for session_data_item in session_data:
if not isinstance(session_data_item, dict):
continue
session_id = str(session_data_item.get("id") or "")
session_directory = session_data_item.get("directory")
if session_id and session_directory and Path(str(session_directory)).resolve() == workdir:
return session_id
session = requests.post(
f"{settings['origin']}/session",
params=params,
json={},
timeout=timeout,
)
session.raise_for_status()
session_data = session.json()
session_id = str(session_data.get("id") or "")
session_directory = session_data.get("directory")
if not session_id or not session_directory or Path(str(session_directory)).resolve() != workdir:
raise RuntimeError(
"OpenCode did not create a session for the requested worktree.",
)
return session_id
def _owned_process_running() -> bool:
process = _PROCESS
return process is not None and process.poll() is None
def _owned_process_ready() -> bool:
process = _PROCESS_READY
return process is not None and process is _PROCESS and process.poll() is None
def _health(settings: dict, timeout: float = 2) -> bool:
try:
response = requests.get(
f"{settings['origin']}/global/health",
timeout=timeout,
)
return response.status_code == 200
except requests.RequestException:
return False
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS, _PROCESS_READY
started_process: subprocess.Popen | None = None
workdir = settings["workdir"].resolve()
with _PROCESS_LOCK:
if _owned_process_ready():
return True, ""
if _health(settings):
if _owned_process_running():
_PROCESS_READY = _PROCESS
return True, ""
if _owned_process_running():
_stop_owned_process(_PROCESS)
try:
binary, git_binary, binary_env = _resolve_binary(
settings["binary"],
settings["config"],
)
except RuntimeError as err:
return False, str(err)
env = {
**os.environ,
**binary_env,
"ARCHIVEBOX_BASE_URL": str(settings.get("archivebox_base_url", "")),
"ARCHIVEBOX_ADMIN_URL": str(settings.get("archivebox_admin_url", "")),
"ARCHIVEBOX_API_URL": str(settings.get("archivebox_api_url", "")),
"BROWSER": "false",
"GIT_CEILING_DIRECTORIES": str(workdir),
"HOME": str(settings["home"]),
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
"XDG_CONFIG_HOME": str(settings["config_home"]),
"XDG_DATA_HOME": str(settings["data_home"]),
"XDG_STATE_HOME": str(settings["state_home"]),
"XDG_CACHE_HOME": str(settings["cache_home"]),
}
settings["workdir"].mkdir(parents=True, exist_ok=True)
settings["config_home"].mkdir(parents=True, exist_ok=True)
settings["data_home"].mkdir(parents=True, exist_ok=True)
settings["state_home"].mkdir(parents=True, exist_ok=True)
settings["cache_home"].mkdir(parents=True, exist_ok=True)
settings["home"].mkdir(parents=True, exist_ok=True)
_ensure_project_files(settings)
if not (workdir / ".git").exists():
try:
git_init = git_binary.exec(
cmd=("init", "--quiet"),
cwd=workdir,
env=env,
timeout=settings["timeout"],
)
except (AssertionError, OSError, subprocess.SubprocessError) as err:
return False, f"OpenCode project initialization failed: {err}"
if git_init.returncode != 0:
output = (git_init.stderr or git_init.stdout or "").strip()
return (
False,
f"OpenCode project initialization failed: {output or f'git exited with {git_init.returncode}'}",
)
if _health(settings):
return True, ""
binary_abspath = binary.loaded_abspath
if binary.loaded_binprovider is not None:
binary_abspath = binary.loaded_binprovider._exec_bin_abspath(
Path(binary.loaded_abspath),
)
cmd = [
str(binary_abspath),
"serve",
"--hostname",
settings["host"],
"--port",
str(settings["port"]),
]
try:
_PROCESS = subprocess.Popen(
cmd,
cwd=workdir,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
start_new_session=True,
)
_PROCESS_READY = None
started_process = _PROCESS
except FileNotFoundError:
return False, f"OpenCode binary not found: {settings['binary']}"
deadline = time.monotonic() + settings["timeout"]
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
if _health(settings, timeout=min(2, remaining)):
if time.monotonic() <= deadline:
_PROCESS_READY = started_process
return True, ""
break
if started_process and started_process.poll() is not None:
if _PROCESS is started_process:
_PROCESS = None
if _PROCESS_READY is started_process:
_PROCESS_READY = None
return False, "OpenCode exited before the web server became ready."
remaining = deadline - time.monotonic()
if remaining > 0:
time.sleep(min(0.25, remaining))
_stop_owned_process(started_process)
return False, "Timed out waiting for OpenCode to start."
def agent_view(request: HttpRequest):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
settings = _settings(config)
route_config = request.__dict__.get("archivebox_config")
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
settings["archivebox_base_url"] = base_url
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
recent_session_id = ""
if ok:
try:
with _SESSION_LOCK:
recent_session_id = _ensure_default_session(settings)
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
context = {
**archivebox_admin.each_context(request),
"title": "Agent",
"error": "" if ok else error,
"command": f"{settings['binary']} serve --hostname {settings['host']} --port {settings['port']}" if error else "",
# OpenCode 1.17+ keeps durable sessions on the explicit
# /<dirBase64>/session/<sessionId> route. We still seed localStorage
# below because the sidebar state uses it, but the iframe itself must
# open the durable session URL so a fresh browser does not land on the
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"proxy_prefix": _PROXY_PREFIX,
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
return render(
request,
"opencode/agent.html",
context,
status=200 if ok else 502,
)
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)
def _request_header(request: HttpRequest, name: str) -> str | None:
meta = getattr(request, "META", {})
if name == "Content-Type":
value = meta.get("CONTENT_TYPE")
elif name == "Content-Length":
value = meta.get("CONTENT_LENGTH")
else:
value = meta.get(f"HTTP_{name.upper().replace('-', '_')}")
return str(value) if value else None
def _request_headers(request: HttpRequest, settings: dict) -> dict[str, str]:
forwarded = {}
for key in ("Accept", "Accept-Language", "Content-Type", "Range", "User-Agent"):
value = _request_header(request, key)
if value:
forwarded[key] = value
return forwarded
def _request_params(request: HttpRequest) -> tuple[tuple[str, str], ...]:
if hasattr(request.GET, "lists"):
return tuple((key, str(value)) for key, values in request.GET.lists() for value in values)
return tuple((key, str(value)) for key, value in dict(request.GET).items())
async def _event_chunks(
settings: dict,
path: str | None,
method: str,
params: tuple[tuple[str, str], ...],
headers: dict[str, str],
):
if not _owned_process_ready():
ok, error = await sync_to_async(_ensure_opencode, thread_sensitive=False)(settings)
if not ok:
_LOGGER.warning("OpenCode event stream unavailable: %s", error)
yield b'event: error\ndata: {"error":"OpenCode upstream unavailable"}\n\n'
return
timeout = httpx.Timeout(settings["timeout"], read=None)
url = _proxy_url(settings, path)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(
method,
url,
params=params,
headers=headers,
) as upstream:
async for chunk in upstream.aiter_raw(chunk_size=512):
yield chunk
except httpx.RequestError as err:
_LOGGER.warning("OpenCode event stream ended: %s", err)
def _rewrite_text(body: bytes, settings: dict) -> bytes:
text = body.decode("utf-8", errors="replace")
text = text.replace(settings["origin"], _PROXY_PREFIX)
text = text.replace("location.origin", f'location.origin+"{_PROXY_PREFIX}"')
text = text.replace(
"k(k5,{get component(){return t.router??Az},",
f'k(k5,{{base:"{_PROXY_PREFIX}",get component(){{return t.router??Az}},',
)
text = text.replace('"/assets/', f'"{_PROXY_PREFIX}/assets/')
text = text.replace("'/assets/", f"'{_PROXY_PREFIX}/assets/")
proxy_path = rf'(\1.replace(/^{_PROXY_PREFIX_REGEX}(?=\/|$)/,"")||"/")'
text = re.sub(r"\b(window\.location\.pathname)\b", proxy_path, text)
text = re.sub(r"(?<![.\w])(location\.pathname)\b", proxy_path, text)
text = text.replace(
'window.history.replaceState(nz(o),"",r):window.history.pushState(o,"",r)',
(
f'window.history.replaceState(nz(o),"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r):'
f'window.history.pushState(o,"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r)'
),
)
text = text.replace(
'const BL="modulepreload",UL=function(t){return"/"+t}',
f'const BL="modulepreload",UL=function(t){{return"{_PROXY_PREFIX}/"+t}}',
)
text = re.sub(
rf"""(?P<prefix>\b(?:href|src|action)=["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
text = re.sub(
rf"""(?P<prefix>\b(?:fetch|EventSource)\(["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
text = re.sub(
rf"""(?P<prefix>\burl\(["']?)/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
return text.encode("utf-8")
def _response_headers(upstream: requests.Response, settings: dict) -> dict[str, str]:
headers = {}
for key, value in upstream.headers.items():
lower = key.lower()
if lower in _HOP_BY_HOP_HEADERS or lower in {
"content-length",
"content-encoding",
"x-frame-options",
}:
continue
if lower == "location":
if value.startswith(settings["origin"]):
value = value.replace(settings["origin"], _PROXY_PREFIX, 1)
elif value.startswith("/"):
value = f"{_PROXY_PREFIX}{value}"
headers[key] = value
return headers
def _proxy_error_response(error: Exception | str) -> HttpResponse:
_LOGGER.warning("OpenCode upstream request failed: %s", error)
return HttpResponse(
b"OpenCode upstream request failed.",
status=502,
content_type="text/plain; charset=utf-8",
)
def agent_view(request):
return _dispatch(request)
@csrf_exempt
def opencode_proxy_view(request: HttpRequest, path: str | None = None):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
if not _origin_allowed(request, path):
return HttpResponseForbidden(
b"Cross-origin OpenCode agent requests are blocked.",
)
settings = _settings(config)
route_config = request.__dict__.get("archivebox_config")
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
settings["archivebox_base_url"] = base_url
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
if request.method == "GET" and (path or "").endswith("/event"):
response = StreamingHttpResponse(
_event_chunks(
settings,
path,
request.method or "GET",
_request_params(request),
_request_headers(request, settings),
),
content_type="text/event-stream",
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Accel-Buffering"] = "no"
return response
if path == "global/health" or not _owned_process_ready():
ok, error = _ensure_opencode(settings)
if not ok:
return _proxy_error_response(error)
try:
method = request.method or "GET"
upstream = requests.request(
method,
_proxy_url(settings, path),
params=_request_params(request),
data=request.body if method not in {"GET", "HEAD"} else None,
headers=_request_headers(request, settings),
stream=True,
timeout=settings["timeout"],
allow_redirects=False,
)
except requests.RequestException as err:
return _proxy_error_response(err)
try:
body = upstream.content
except requests.RequestException as err:
upstream.close()
return _proxy_error_response(err)
content_type = upstream.headers.get("Content-Type", "")
headers = _response_headers(upstream, settings)
if any(content_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES):
body = _rewrite_text(body, settings)
response = HttpResponse(
body,
status=upstream.status_code,
content_type=content_type or "application/octet-stream",
)
for key, value in headers.items():
response.headers[key] = value
response.headers["Cache-Control"] = "no-store"
return response
def opencode_proxy_view(request, path=None):
return _dispatch(request, path=path or "")

View File

@ -1,6 +1,6 @@
{% extends "core/base.html" %}
{% load static %}
{% load static core_tags %}
{% load i18n %}
{% block breadcrumbs %}
@ -48,9 +48,7 @@
<div class="crawl-explanation">
<p class="crawl-tip" style="user-select: none;">
<span style="float: right">
{% if user.is_authenticated and user.is_superuser and request.archivebox_config.OPENCODE_ENABLED %}
<a href="/admin/agent">💬 Crawl with AI</a> &nbsp; | &nbsp;
{% endif %}
{% plugin_ui "opencode" "add" %}
<a href="https://github.com/ArchiveBox/archivebox-browser-extension"><img src="{% static 'chrome_extension_icon.png' %}" alt="Chrome Extension" style="height: 33px; margin-top: -5px;">&nbsp; Get the extension</a>
</span>
💡 <strong>Tip:</strong> Instantly save a single URL by visiting:

View File

@ -1,4 +1,4 @@
{% load i18n static %}
{% load i18n static core_tags %}
<div id="user-tools">
<a href="{% url 'add' %}" class="navbar-item navbar-add-link">Add </a>
@ -11,10 +11,7 @@
<span class="navbar-separator" aria-hidden="true">|</span>
<a href="/admin/core/tag/" class="navbar-item navbar-tags">Tags</a>
<span class="navbar-separator navbar-separator-wide" aria-hidden="true"></span>
{% if user.is_authenticated and user.is_superuser and request.archivebox_config.OPENCODE_ENABLED %}
<a href="/admin/agent" class="navbar-item navbar-ai">💬 AI</a>
<span class="navbar-separator" aria-hidden="true">|</span>
{% endif %}
{% plugin_ui "opencode" "navigation" %}
<a href="{% url 'Docs' %}" class="navbar-item navbar-docs" target="_blank" rel="noopener noreferrer">Docs</a>
<span class="navbar-separator" aria-hidden="true">|</span>
<a href="/api/v1/docs" class="navbar-item navbar-api">API</a>

View File

@ -1,19 +1,18 @@
import asyncio
import json
import os
import signal
import shutil
import socket
import subprocess
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
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)
@ -78,8 +77,8 @@ def opencode_archive_config(initialized_archive):
@pytest.fixture
def live_opencode(opencode_archive_config):
from archivebox.opencode import views
def installed_opencode(opencode_archive_config):
from abx_plugins.plugins.opencode import runtime
install = run_archivebox_cmd(
["install", "opencode", "--binproviders=env,pnpm"],
@ -90,56 +89,61 @@ def live_opencode(opencode_archive_config):
assert install.returncode == 0, install.stderr or install.stdout
_reset_runtime_config()
config = views._machine_config()
settings = views._settings(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 = views._resolve_binary(settings["binary"], settings["config"])
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
ok, error = views._ensure_opencode(settings)
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 = views._PROCESS
process = runtime._PROCESS
assert process is not None
try:
yield SimpleNamespace(config=opencode_archive_config, settings=settings, process=process)
yield SimpleNamespace(config=installed_opencode.config, settings=settings, process=process)
finally:
views._stop_owned_process()
runtime._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
from abx_plugins.plugins.opencode import runtime
os.chdir(initialized_archive)
Machine.from_json({"config": {"OPENCODE_ENABLED": False}})
_reset_runtime_config()
assert views._machine_config()["OPENCODE_ENABLED"] is False
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 views._PROCESS is None or views._PROCESS.poll() is not None
assert runtime._PROCESS is None or runtime._PROCESS.poll() is not None
def test_stop_owned_process_falls_back_for_stopped_process_without_dedicated_group():
from archivebox.opencode import views
def test_opencode_disabled_via_cli_stays_disabled(admin_client, initialized_archive):
_set_archivebox_config(initialized_archive, "OPENCODE_ENABLED=False")
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()
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):
@ -147,6 +151,11 @@ def test_opencode_agent_requires_superuser_when_enabled(client, db, django_user_
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)
@ -178,18 +187,22 @@ def test_opencode_proxy_blocks_cross_site_fetch_metadata(admin_client, db, live_
def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode):
from archivebox.opencode import views
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 = views._project_route(live_opencode.config.data_dir, 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'<iframe src="{session_path}"'.encode() in response.content
assert b'id="header"' in response.content
assert b'id="progress-monitor"' in response.content
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
assert b'<a href="/admin/agent" class="navbar-item navbar-ai">' in response.content
add_page = admin_client.get("/add/", HTTP_HOST=ADMIN_TEST_HOST)
assert add_page.status_code == 200
assert '<a href="/admin/agent">💬 Crawl with AI</a>'.encode() in add_page.content
assert response.context["proxy_prefix"] == runtime._PROXY_PREFIX
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"
@ -218,7 +231,8 @@ def test_opencode_proxy_serves_real_project_and_session(admin_client, live_openc
HTTP_SEC_FETCH_SITE="same-origin",
)
assert project.status_code == 200
assert workdir.encode() in project.content
assert project.json()["id"] == "global"
assert not project.json().get("vcs")
path = admin_client.get(
f"/admin/agent/opencode/path?directory={encoded_workdir}",
@ -226,7 +240,7 @@ def test_opencode_proxy_serves_real_project_and_session(admin_client, live_openc
HTTP_SEC_FETCH_SITE="same-origin",
)
assert path.status_code == 200
assert workdir.encode() in path.content
assert path.json()["directory"] == workdir
sessions = admin_client.get(
f"/admin/agent/opencode/session?directory={encoded_workdir}&roots=true&limit=55",
@ -234,14 +248,15 @@ def test_opencode_proxy_serves_real_project_and_session(admin_client, live_openc
HTTP_SEC_FETCH_SITE="same-origin",
)
assert sessions.status_code == 200
assert b"id" in sessions.content
assert any(session["id"] == agent.context["recent_session_id"] and session["directory"] == workdir for session in sessions.json())
assert not (Path(workdir) / ".git").exists()
def test_opencode_proxy_restarts_server_for_an_existing_agent_page(admin_client, live_opencode):
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
old_process = views._PROCESS
views._stop_owned_process()
old_process = runtime._PROCESS
runtime._stop_owned_process()
response = admin_client.get(
"/admin/agent/opencode/global/health",
@ -250,45 +265,45 @@ def test_opencode_proxy_restarts_server_for_an_existing_agent_page(admin_client,
)
assert response.status_code == 200
assert views._PROCESS is not None
assert views._PROCESS is not old_process
assert views._PROCESS.poll() is None
assert runtime._PROCESS is not None
assert runtime._PROCESS is not old_process
assert runtime._PROCESS.poll() is None
def test_concurrent_opencode_startup_waits_until_server_is_ready(live_opencode):
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
views._stop_owned_process()
runtime._stop_owned_process()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(views._ensure_opencode, [live_opencode.settings] * 2))
results = list(executor.map(runtime._ensure_opencode, [live_opencode.settings] * 2))
assert results == [(True, ""), (True, "")]
assert views._health(live_opencode.settings)
assert runtime._health(live_opencode.settings)
def test_opencode_does_not_probe_or_replace_a_ready_owned_process(live_opencode):
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
process = views._PROCESS
process = runtime._PROCESS
settings = {**live_opencode.settings, "port": _free_port()}
settings["origin"] = f"http://{settings['host']}:{settings['port']}"
ok, error = views._ensure_opencode(settings)
ok, error = runtime._ensure_opencode(settings)
assert ok, error
assert process is not None
assert views._PROCESS is process
assert runtime._PROCESS is process
assert process.poll() is None
def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_opencode):
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
workdir = quote(str(live_opencode.config.data_dir.resolve()))
assert views._owned_process_ready()
assert runtime._owned_process_ready()
executor = ThreadPoolExecutor(max_workers=1)
views._PROCESS_LOCK.acquire()
runtime._PROCESS_LOCK.acquire()
try:
request = executor.submit(
admin_client.get,
@ -298,7 +313,7 @@ def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_openc
)
response = request.result(timeout=5)
finally:
views._PROCESS_LOCK.release()
runtime._PROCESS_LOCK.release()
executor.shutdown(wait=True)
assert response.status_code == 200
@ -306,11 +321,11 @@ def test_opencode_proxy_does_not_wait_for_recovery_lock(admin_client, live_openc
def test_opencode_proxy_waits_for_owned_process_readiness(admin_client, live_opencode):
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
process = views._PROCESS
process = runtime._PROCESS
assert process is not None
views._PROCESS_READY = None
runtime._PROCESS_READY = None
workdir = quote(str(live_opencode.config.data_dir.resolve()))
response = admin_client.get(
@ -320,8 +335,8 @@ def test_opencode_proxy_waits_for_owned_process_readiness(admin_client, live_ope
)
assert response.status_code == 200
assert views._PROCESS is process
assert views._PROCESS_READY is process
assert runtime._PROCESS is process
assert runtime._PROCESS_READY is process
def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
@ -340,10 +355,10 @@ def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
def test_opencode_proxy_sse_returns_headers_before_restart_finishes(admin_client, live_opencode):
from archivebox.core.asgi import application
from archivebox.opencode import views
from abx_plugins.plugins.opencode import runtime
from django.conf import settings as django_settings
owned_process = views._PROCESS
owned_process = runtime._PROCESS
assert owned_process is not None
session_cookie_name = django_settings.SESSION_COOKIE_NAME
session_cookie = admin_client.cookies[session_cookie_name].value
@ -370,8 +385,8 @@ def test_opencode_proxy_sse_returns_headers_before_restart_finishes(admin_client
"server": ("127.0.0.1", 8000),
},
)
views._PROCESS_LOCK.acquire()
views._PROCESS = None
runtime._PROCESS_LOCK.acquire()
runtime._PROCESS = None
try:
await communicator.send_input({"type": "http.request", "body": b"", "more_body": False})
response_start = await communicator.receive_output(timeout=2)
@ -382,19 +397,25 @@ def test_opencode_proxy_sse_returns_headers_before_restart_finishes(admin_client
await communicator.send_input({"type": "http.disconnect"})
await communicator.wait(timeout=5)
finally:
views._PROCESS = owned_process
views._PROCESS_LOCK.release()
runtime._PROCESS = owned_process
runtime._PROCESS_LOCK.release()
await asyncio.get_running_loop().shutdown_default_executor()
asyncio.run(request_event_stream())
assert views._PROCESS is owned_process
assert runtime._PROCESS is owned_process
assert owned_process.poll() is None
def test_opencode_starts_with_isolated_state(live_opencode):
def test_opencode_starts_with_isolated_state(admin_client, live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
state_dir = live_opencode.config.state_dir
assert not (Path(workdir) / ".git").exists()
agent = admin_client.get("/admin/agent", HTTP_HOST=ADMIN_TEST_HOST)
assert agent.status_code == 200
assert agent.context["recent_session_id"]
assert not (Path(workdir) / ".git").exists()
project = requests.get(
f"{live_opencode.settings['origin']}/project/current",
params={"directory": workdir},
@ -409,89 +430,112 @@ def test_opencode_starts_with_isolated_state(live_opencode):
config.raise_for_status()
assert Path(live_opencode.settings["workdir"]).resolve() == Path(workdir)
assert Path(str(project.json()["worktree"])).resolve() == Path(workdir)
assert project.json()["id"] == "global"
assert not project.json().get("vcs")
assert config.json()["model"] == "opencode/big-pickle"
assert config.json()["snapshot"] is False
assert live_opencode.process.poll() is None
assert (live_opencode.config.data_dir / ".git").is_dir()
path = requests.get(
f"{live_opencode.settings['origin']}/path",
params={"directory": workdir},
timeout=live_opencode.settings["timeout"],
)
path.raise_for_status()
assert Path(path.json()["directory"]).resolve() == Path(workdir)
diff = requests.get(
f"{live_opencode.settings['origin']}/vcs/diff",
params={"directory": workdir, "mode": "git"},
timeout=5,
)
diff.raise_for_status()
assert diff.json() == []
assert (state_dir / "data" / "opencode" / "opencode.db").is_file()
assert (state_dir / "SKILL.md").is_file()
assert (state_dir / "config" / "opencode" / "skills" / "archivebox" / "SKILL.md").resolve() == state_dir / "SKILL.md"
def test_opencode_state_dir_is_separate_from_workdir(tmp_path):
from archivebox.opencode import views
def test_opencode_invalid_state_does_not_break_archivebox(admin_client, live_opencode):
from abx_plugins.plugins.opencode import runtime
workdir = tmp_path / "workdir"
state_dir = tmp_path / "state"
settings = views._settings(
{
"OPENCODE_WORKDIR": str(workdir),
"OPENCODE_STATE_DIR": str(state_dir),
},
)
views._ensure_project_files(settings)
runtime._stop_owned_process()
invalid_state = live_opencode.config.state_dir / "config"
invalid_state.rename(live_opencode.config.state_dir / "saved-config")
invalid_state.write_text("Preserve this file.")
assert settings["workdir"] == workdir
assert settings["opencode_dir"] == state_dir
assert settings["config_home"] == state_dir / "config"
assert settings["data_home"] == state_dir / "data"
assert settings["state_home"] == state_dir / "state"
editable_skill = state_dir / "SKILL.md"
loaded_skill = state_dir / "config" / "opencode" / "skills" / "archivebox" / "SKILL.md"
assert editable_skill.exists()
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()
opencode_config = json.loads((state_dir / "config" / "opencode" / "opencode.jsonc").read_text())
assert opencode_config["model"] == "opencode/big-pickle"
assert opencode_config["snapshot"] is False
for url in ("/admin/agent", "/admin/agent/opencode/global/health"):
response = admin_client.get(url, HTTP_HOST=ADMIN_TEST_HOST)
assert response.status_code == 503
assert str(invalid_state).encode() not in response.content
stream = admin_client.get("/admin/agent/opencode/global/event", HTTP_HOST=ADMIN_TEST_HOST)
assert stream.status_code == 200
async def read_failure():
return b"".join([chunk async for chunk in stream.streaming_content])
assert asyncio.run(read_failure()) == b'event: error\ndata: {"error":"OpenCode unavailable"}\n\n'
for url in ("/health/", "/add/", "/admin/core/snapshot/"):
response = admin_client.get(url, HTTP_HOST=ADMIN_TEST_HOST)
assert response.status_code == 200
assert invalid_state.read_text() == "Preserve this file."
@pytest.mark.parametrize(
"existing_config",
("damaged_file", "missing"),
[
'{"model": "anthropic/claude-sonnet-4-5"}\n',
'{\n // Keep the administrator-selected model.\n "model": "anthropic/claude-sonnet-4-5",\n}\n',
'{\n // Schema-only files are still user-owned.\n "$schema": "https://opencode.ai/config.json",\n}\n',
("runtime.py", True),
("templates/agent.html", True),
("templates/agent.html", False),
("templates/navigation.html", False),
("templates/add.html", False),
],
)
def test_opencode_preserves_existing_config(tmp_path, existing_config):
from archivebox.opencode import views
def test_opencode_incomplete_install_does_not_break_archivebox(installed_opencode, tmp_path, damaged_file, missing):
import abx_plugins
state_dir = tmp_path / "state"
config_path = state_dir / "config" / "opencode" / "opencode.jsonc"
config_path.parent.mkdir(parents=True)
config_path.write_text(existing_config)
views._ensure_project_files(
views._settings(
{
"OPENCODE_WORKDIR": str(tmp_path / "workdir"),
"OPENCODE_STATE_DIR": str(state_dir),
},
),
# Exercise a genuinely incomplete installation in a separate process;
# never alter the shared package or intercept Python imports.
site = tmp_path / "site"
installed = site / "abx_plugins"
shutil.copytree(Path(abx_plugins.__file__).parent, installed, ignore=shutil.ignore_patterns("__pycache__", "tests"))
damaged_path = installed / "plugins" / "opencode" / damaged_file
if missing:
damaged_path.unlink()
else:
damaged_path.write_text("{% invalid_template_tag %}")
expected_status = 200 if damaged_file in {"templates/navigation.html", "templates/add.html"} else 503
script = f"""
import sys
from pathlib import Path
import abx_plugins
from django.test import Client
from django.contrib.auth import get_user_model
assert Path(abx_plugins.__file__).is_relative_to({str(site)!r})
user = get_user_model().objects.create_superuser(username='optional-service-test')
client = Client(HTTP_HOST={ADMIN_TEST_HOST!r})
client.force_login(user)
for path in ('/health/', '/add/', '/admin/core/snapshot/'):
assert client.get(path).status_code == 200, path
assert 'abx_plugins.plugins.opencode.runtime' not in sys.modules
response = client.get('/admin/agent')
assert response.status_code == {expected_status}, response.status_code
if response.status_code == 503:
assert response.content == b'AI service unavailable. See server logs.'
if {damaged_file != "runtime.py"!r}:
from abx_plugins.plugins.opencode import runtime
assert runtime._PROCESS is not None
assert runtime._PROCESS.poll() is None
for path in ('/health/', '/add/', '/admin/core/snapshot/'):
assert client.get(path).status_code == 200, path
print('OPTIONAL_SERVICE_FAILURE_ISOLATED')
"""
result = run_archivebox_cmd(
["shell", "-c", script],
cwd=installed_opencode.config.data_dir,
env={**installed_opencode.config.env, "PYTHONPATH": str(site)},
timeout=90,
)
assert config_path.read_text() == existing_config
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["archivebox_data_dir"]
assert settings["timeout"] == 120
def test_opencode_rewrites_vite_preload_assets():
from archivebox.opencode import views
body = b'const BL="modulepreload",UL=function(t){return"/"+t};const icon="/assets/sprite.svg#anthropic"'
rewritten = views._rewrite_text(body, {"origin": "http://127.0.0.1:4096"}).decode()
assert 'return"/"+t' not in rewritten
assert 'return"/admin/agent/opencode/"+t' in rewritten
assert '"/admin/agent/opencode/assets/sprite.svg#anthropic"' in rewritten
assert result.returncode == 0, result.stderr or result.stdout
assert "OPTIONAL_SERVICE_FAILURE_ISOLATED" in result.stdout

View File

@ -0,0 +1,154 @@
"""Exercise the real agent wrapper with Chromium's native storage failures."""
import json
import os
import subprocess
import pytest
from .conftest import get_free_port, run_archivebox_cmd, start_archivebox_server, stop_archivebox_process
from .test_opencode_agent import _set_archivebox_config
from .test_opencode_agent import installed_opencode as installed_opencode
from .test_opencode_agent import opencode_archive_config as opencode_archive_config
from .test_server_security_browser import browser_runtime as browser_runtime
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture
def agent_server(installed_opencode, browser_runtime):
port = get_free_port()
url = f"http://localhost:{port}"
config = installed_opencode.config
_set_archivebox_config(
config.data_dir,
f"BASE_URL={url}",
"SERVER_SECURITY_MODE=safe-onedomain-nojsreplay",
)
user = run_archivebox_cmd(
[
"shell",
"-c",
"from django.contrib.auth import get_user_model; get_user_model().objects.create_superuser(username='agent-browser-test', password='test-password')",
],
cwd=config.data_dir,
env=config.env,
)
assert user.returncode == 0, user.stderr or user.stdout
process = start_archivebox_server(config.data_dir, port=port, env=config.env, log_name="agent-browser-server.log")
try:
yield url, config.data_dir
finally:
stop_archivebox_process(process)
def test_agent_preserves_projects_and_survives_storage_failure(agent_server, browser_runtime):
server_url, data_dir = agent_server
script = r"""
const assert = require('node:assert/strict');
const puppeteer = require('puppeteer');
const config = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
(async () => {
for (const disabled of [false, true]) {
const browser = await puppeteer.launch({
executablePath: config.chrome,
headless: true,
// Headless interaction must not depend on the desktop display clock.
args: ['--no-sandbox', '--disable-frame-rate-limit', ...(disabled ? ['--disable-local-storage'] : [])],
});
try {
const page = await browser.newPage();
await page.goto(new URL('/admin/login/', config.url).href, {waitUntil: 'domcontentloaded'});
await page.locator('#login-form input[name="username"]').fill('agent-browser-test');
await page.locator('#login-form input[name="password"]').fill('test-password');
assert.equal(await page.$eval('#login-form input[name="username"]', element => element.value), 'agent-browser-test');
assert.equal(await page.$eval('#login-form input[name="password"]', element => element.value), 'test-password');
await Promise.all([
page.waitForNavigation({waitUntil: 'domcontentloaded'}),
page.locator('#login-form input[type="submit"]').click(),
]);
assert.ok(!page.url().includes('/admin/login/'), page.url());
assert.equal((await page.goto(config.url, {waitUntil: 'domcontentloaded'})).status(), 200);
const welcome = '#opencode-agent-welcome';
await page.waitForSelector(welcome, {visible: true});
if (disabled) {
assert.equal(await page.evaluate(() => {
try { return localStorage === null; }
catch (error) { if (error.name !== 'SecurityError') throw error; return true; }
}), true);
} else {
await page.click('#opencode-agent-welcome-dismiss');
await page.waitForSelector(welcome, {hidden: true});
await page.reload({waitUntil: 'domcontentloaded'});
assert.equal(await page.$eval(welcome, element => element.hidden), true);
const existing = {
list: [{type: 'http', http: {url: 'http://other.example'}}],
projects: {
local: [{worktree: '/other-project', expanded: false}],
'http://other.example': [{worktree: '/remote-project', expanded: true}],
},
lastProject: {'http://other.example': '/remote-project'},
};
await page.evaluate(value => localStorage.setItem('opencode.global.dat:server', JSON.stringify(value)), existing);
await page.reload({waitUntil: 'domcontentloaded'});
await page.waitForFunction(workdir => {
const state = JSON.parse(localStorage.getItem('opencode.global.dat:server'));
return state.projects.local?.some(project => project.worktree === workdir);
}, {}, config.workdir);
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('opencode.global.dat:server')));
assert.deepEqual(saved.list, existing.list);
assert.deepEqual(saved.projects['http://other.example'], existing.projects['http://other.example']);
assert.equal(saved.lastProject['http://other.example'], '/remote-project');
const projects = saved.projects.local;
assert.ok(Array.isArray(projects), JSON.stringify({url: page.url(), saved, existing}));
assert.deepEqual(projects.find(project => project.worktree === '/other-project'), {worktree: '/other-project', expanded: false});
assert.equal(projects.filter(project => project.worktree === config.workdir).length, 1);
await page.reload({waitUntil: 'domcontentloaded'});
assert.equal(await page.evaluate(workdir => {
const state = JSON.parse(localStorage.getItem('opencode.global.dat:server'));
return state.projects.local.filter(project => project.worktree === workdir).length;
}, config.workdir), 1);
// Fill the actual browser quota; do not replace or intercept Storage methods.
const failure = await page.evaluate(() => {
localStorage.clear();
let low = 0, high = 8 * 1024 * 1024;
while (low < high) {
const size = Math.ceil((low + high) / 2);
try { localStorage.setItem('padding', 'x'.repeat(size)); low = size; }
catch (error) { if (error.name !== 'QuotaExceededError') throw error; high = size - 1; }
}
try { localStorage.setItem('extra', '1'); return null; }
catch (error) { return error.name; }
});
assert.equal(failure, 'QuotaExceededError');
await page.reload({waitUntil: 'domcontentloaded'});
await page.waitForSelector(welcome, {visible: true});
}
await page.click('#opencode-agent-welcome-dismiss');
await page.waitForSelector(welcome, {hidden: true});
assert.equal((await page.goto(new URL('/add/', config.url).href, {waitUntil: 'domcontentloaded'})).status(), 200);
} finally {
await browser.close();
}
}
console.log('AGENT_STORAGE_FAILURE_ISOLATED');
})().catch(error => { console.error(error); process.exitCode = 1; });
"""
result = subprocess.run(
[str(browser_runtime["node_binary"]), "-e", script],
input=json.dumps(
{
"chrome": str(browser_runtime["chrome_binary"]),
"url": f"{server_url}/admin/agent",
"workdir": str(data_dir.resolve()),
},
),
env={**os.environ, "NODE_PATH": browser_runtime["node_path"]},
text=True,
capture_output=True,
timeout=120,
)
assert result.returncode == 0, result.stderr or result.stdout
assert "AGENT_STORAGE_FAILURE_ISOLATED" in result.stdout

View File

@ -69,7 +69,6 @@ dependencies = [
"django-stubs>=5.0.4", # for: vscode type hints on models and common django APIs
### API clients
"requests>=2.32.3", # for: fetching title, static files, headers (TODO: replace with httpx?)
"httpx>=0.28.1", # for: streaming the admin-only OpenCode proxy
"sonic-client>=1.0.0",
### Parsers
"dateparser>=1.2.0", # for: parsing pocket/pinboard/etc. RSS/bookmark import dates
@ -80,7 +79,7 @@ dependencies = [
### Binary/Package Management
"abxbus==2.5.56", # direct imports only; version constrained by abx-dl -> abx-plugins
"abxpkg==1.12.117", # direct imports only; version constrained by abx-dl -> abx-plugins
"abx-plugins==1.12.227", # direct imports only; version constrained by abx-dl
"abx-plugins[opencode]==1.12.227", # optional AI runtime clients are owned by the plugin
"abx-dl==1.12.264", # shared ArchiveBox downloader package
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13

20
uv.lock
View File

@ -13,11 +13,11 @@ supported-markers = [
]
[options]
exclude-newer = "2026-08-29T22:51:51.983072377Z"
exclude-newer = "2026-08-29T22:57:32.674293Z"
exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "2026-09-03T22:51:50.983085661Z", span = "PT1S" }
abxbus = { timestamp = "2026-09-03T22:57:31.674343Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
@ -38,9 +38,9 @@ dependencies = [
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "rich-click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/48/d8c1a751169814fdccf2012d5337abce702d31828797051e8b7262c2d20c/abx_dl-1.12.264.tar.gz", hash = "sha256:c4e23367673ba2cb415865e77b2e4992b3afe21df082204d2a3cad0a20a55767", size = 86799 }
sdist = { url = "https://files.pythonhosted.org/packages/fd/48/d8c1a751169814fdccf2012d5337abce702d31828797051e8b7262c2d20c/abx_dl-1.12.264.tar.gz", hash = "sha256:c4e23367673ba2cb415865e77b2e4992b3afe21df082204d2a3cad0a20a55767", size = 86799, upload-time = "2026-09-03T22:49:00.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/b8/317fc6d8b93dc86d9bf300d97701490eb0be197424c95406b52d93548818/abx_dl-1.12.264-py3-none-any.whl", hash = "sha256:5b38f675f98f9648e5a742729a652e57815bc1d54e0b1a4d5f9d84e67f03858b", size = 92071 },
{ url = "https://files.pythonhosted.org/packages/e0/b8/317fc6d8b93dc86d9bf300d97701490eb0be197424c95406b52d93548818/abx_dl-1.12.264-py3-none-any.whl", hash = "sha256:5b38f675f98f9648e5a742729a652e57815bc1d54e0b1a4d5f9d84e67f03858b", size = 92071, upload-time = "2026-09-03T22:48:59.014Z" },
]
[[package]]
@ -59,6 +59,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/0b/f6ad4272b15beccdec036caed70f3e288853e6f707805dd324bb1d07448e/abx_plugins-1.12.227-py3-none-any.whl", hash = "sha256:427bef9961c7e1ed38a256fdea7eea01d9799aae180f2bf832dcb471c12985f5", size = 422585, upload-time = "2026-09-03T22:34:54.348Z" },
]
[package.optional-dependencies]
opencode = [
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
[[package]]
name = "abxbus"
version = "2.5.56"
@ -124,7 +130,7 @@ version = "0.9.35rc427"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "abx-plugins", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "abx-plugins", extra = ["opencode"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "abxbus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "abxpkg", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "atomicwrites", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@ -141,7 +147,6 @@ dependencies = [
{ name = "django-object-actions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "django-signal-webhooks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "django-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "ipython", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@ -220,7 +225,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.12.264" },
{ name = "abx-plugins", specifier = "==1.12.227" },
{ name = "abx-plugins", extras = ["opencode"], specifier = "==1.12.227" },
{ name = "abxbus", specifier = "==2.5.56" },
{ name = "abxpkg", specifier = "==1.12.117" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },
@ -242,7 +247,6 @@ requires-dist = [
{ name = "django-signal-webhooks", specifier = ">=0.3.0" },
{ name = "django-stubs", specifier = ">=5.0.4" },
{ name = "djdt-flamegraph", marker = "extra == 'debug'", specifier = ">=0.2.13" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "ipdb", marker = "extra == 'debug'", specifier = ">=0.13.13" },
{ name = "ipython", specifier = ">=8.27.0" },
{ name = "platformdirs", specifier = ">=4.3.6" },