diff --git a/Dockerfile.multistage b/Dockerfile.multistage
index fee94595..c5116a98 100644
--- a/Dockerfile.multistage
+++ b/Dockerfile.multistage
@@ -74,7 +74,7 @@ ENV UV_COMPILE_BYTECODE=0 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/venv \
VIRTUAL_ENV=/venv \
- PATH="/opt/archivebox/lib/bin:/venv/bin:/opt/node/bin:$PATH"
+ PATH="/venv/bin:/opt/node/bin:$PATH"
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
WORKDIR "$CODE_DIR"
@@ -96,6 +96,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 \
libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxshmfence1 \
fonts-liberation fonts-noto-color-emoji xdg-utils \
+ ffmpeg imagemagick tesseract-ocr openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
# Runtime-owned layers copied from the abx-dl image.
@@ -115,6 +116,8 @@ RUN (echo "[i] Docker build for ArchiveBox multistage starting..." \
&& which uv && uv self version \
) | tee -a /VERSION.txt
+ENV PYTHONDONTWRITEBYTECODE=1
+
FROM archivebox-runtime-base AS archivebox-builder
WORKDIR "$CODE_DIR"
@@ -196,7 +199,15 @@ RUN echo "[+] Initializing image collection..." \
RUN chmod +x "$CODE_DIR"/bin/*.sh \
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "$LIB_DIR" \
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$LIB_BIN_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
- && TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install 2>&1 | tee -a /VERSION.txt \
+ && TIMEOUT=600 gosu "$ARCHIVEBOX_USER" archivebox install search_backend_ripgrep search_backend_sonic 2>&1 | tee -a /VERSION.txt \
+ && "$LIB_DIR/env/bin/chromium" --version | tee -a /VERSION.txt \
+ && "$LIB_DIR/pip/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
+ && /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
+ && /usr/local/bin/sonic --version | tee -a /VERSION.txt \
+ && /venv/bin/supervisord --version | tee -a /VERSION.txt \
+ && ! command -v gcc \
+ && ! command -v g++ \
+ && ! command -v make \
&& gosu "$ARCHIVEBOX_USER" archivebox version 2>&1 | tee -a /VERSION.txt \
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type d -name __pycache__ -prune -exec rm -rf {} + \
&& find /venv "$CODE_DIR" "$LIB_DIR" "$DATA_DIR" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete \
diff --git a/archivebox/cli/archivebox_extract.py b/archivebox/cli/archivebox_extract.py
index bee59f1c..c315401e 100644
--- a/archivebox/cli/archivebox_extract.py
+++ b/archivebox/cli/archivebox_extract.py
@@ -67,8 +67,14 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
else:
# A paused snapshot may still accept explicit maintenance for one
# ArchiveResult, but this path must not transition it back to
- # queued/startable work.
- snapshot.safe_update({"retry_at": timezone.now()}, refresh=False)
+ # queued/startable work. Guard: only set retry_at while the row is
+ # still paused — concurrent resume would otherwise see a stale
+ # retry_at marker.
+ snapshot.safe_update(
+ {"retry_at": timezone.now()},
+ refresh=False,
+ extra_filter={"status": snapshot.StatusChoices.PAUSED},
+ )
crawl = snapshot.crawl
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
@@ -265,8 +271,14 @@ def run_plugins(
# also keep status=paused here: `retry_at` only asks the orchestrator
# to process the queued plugin rows, and run_due_snapshot restores
# retry_at=MAX afterward instead of resuming the snapshot lifecycle.
- for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "modified_at"):
- snapshot.safe_update({"retry_at": queue_at, "modified_at": queue_at}, refresh=False)
+ for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "modified_at"):
+ # Guard the read-time status so we never bump retry_at on a
+ # row that's been re-queued / started by a concurrent runner.
+ snapshot.safe_update(
+ {"retry_at": queue_at, "modified_at": queue_at},
+ refresh=False,
+ extra_filter={"status": snapshot.status},
+ )
else:
# No plugin rows were requested, so this is a full snapshot retry.
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "retry_at", "modified_at"):
@@ -278,6 +290,7 @@ def run_plugins(
"modified_at": queue_at,
},
refresh=False,
+ extra_filter={"status": snapshot.status},
)
if existing_crawl_ids and not requested_rows:
from archivebox.crawls.models import Crawl
@@ -289,7 +302,11 @@ def run_plugins(
}
if crawl.status != Crawl.StatusChoices.STARTED:
update_fields["status"] = Crawl.StatusChoices.QUEUED
- crawl.safe_update(update_fields, refresh=False)
+ crawl.safe_update(
+ update_fields,
+ refresh=False,
+ extra_filter={"status": crawl.status},
+ )
if processed_count == 0:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py
index d7a36af9..c5318163 100644
--- a/archivebox/cli/archivebox_run.py
+++ b/archivebox/cli/archivebox_run.py
@@ -265,8 +265,18 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.filter(id=crawl_id, status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED]).first()
- if crawl is not None and crawl.retry_at is None:
- crawl.safe_update({"retry_at": timezone.now()}, refresh=False)
+ now = timezone.now()
+ # Only re-lease when the row is unscheduled (retry_at IS NULL) or its
+ # existing lease has already expired. A future retry_at means another
+ # worker is already scheduled — don't clobber.
+ if crawl is not None and (crawl.retry_at is None or crawl.retry_at <= now):
+ # extra_filter pins the read-time retry_at so a concurrent worker
+ # that grabbed the lease between our SELECT and UPDATE wins.
+ crawl.safe_update(
+ {"retry_at": now},
+ refresh=False,
+ extra_filter={"retry_at": crawl.retry_at},
+ )
# Only a foreground `archivebox add` gets the interactive "abort current
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
# orchestrators should shut down immediately and cleanly on the first signal.
diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py
index 7345eca2..af0af7b2 100644
--- a/archivebox/cli/archivebox_update.py
+++ b/archivebox/cli/archivebox_update.py
@@ -570,7 +570,11 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
# write to one statement while the migration loop does filesystem
# work outside any transaction. The modified_at CAS prevents this
# repair scan from overwriting a newer Snapshot edit.
- if not snapshot.safe_update({"crawl": crawl}, refresh=False):
+ if not snapshot.safe_update(
+ {"crawl": crawl},
+ refresh=False,
+ extra_filter={"modified_at": snapshot.modified_at},
+ ):
stats["skipped"] += 1
print(f" [{stats['processed']}] Skipped stale snapshot repair: {entry_path.name}")
continue
@@ -579,7 +583,11 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
# Check if needs migration (0.8.x → 0.9.x)
try:
if snapshot.fs_migration_needed:
- if snapshot.safe_update({"retry_at": timezone.now(), "modified_at": timezone.now()}, refresh=False):
+ if snapshot.safe_update(
+ {"retry_at": timezone.now(), "modified_at": timezone.now()},
+ refresh=False,
+ extra_filter={"modified_at": snapshot.modified_at},
+ ):
stats["queued"] += 1
print(f" [{stats['processed']}] Queued filesystem migration: {entry_path.name}")
else:
@@ -659,7 +667,13 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
# do filesystem migration work that belongs in the runner.
# Guard each single-row UPDATE with modified_at so stale scan
# pages cannot overwrite newer runner/admin writes.
- updated += int(snapshot.safe_update(updates, refresh=False))
+ updated += int(
+ snapshot.safe_update(
+ updates,
+ refresh=False,
+ extra_filter={"modified_at": snapshot.modified_at},
+ ),
+ )
print(f" [{label}] updated {updated} rows so far")
now = timezone.now()
@@ -826,7 +840,13 @@ def process_filtered_snapshots(
# with paged_iterator() and writes later, modified_at is the CAS
# guard that prevents stale CLI scans from overwriting a newer
# runner/admin update to the same snapshot.
- updated = int(snapshot.safe_update(update_values, refresh=False))
+ updated = int(
+ snapshot.safe_update(
+ update_values,
+ refresh=False,
+ extra_filter={"modified_at": snapshot.modified_at},
+ ),
+ )
stats["updated_db"] += updated
stats["queued"] += updated if queue_for_archiving else 0
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index f15de884..9ed0c66c 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -270,7 +270,6 @@ class DatabaseConfig(BaseConfigSet):
alias="ARCHIVEBOX_SQLITE_MMAP_SIZE",
ge=0,
)
- SQLITE_TIMEOUT: float = Field(default=30.0, alias="ARCHIVEBOX_SQLITE_TIMEOUT", ge=0)
SQLITE_BUSY_TIMEOUT: int = Field(default=30000, alias="ARCHIVEBOX_SQLITE_BUSY_TIMEOUT", ge=0)
SQLITE_LOCK_RETRY_TIMEOUT: float = Field(default=60.0, alias="ARCHIVEBOX_SQLITE_LOCK_RETRY_TIMEOUT", ge=0)
SQLITE_LOCK_RETRY_INTERVAL: float = Field(default=5.0, alias="ARCHIVEBOX_SQLITE_LOCK_RETRY_INTERVAL", gt=0)
diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py
index f66e0122..dd74fbf1 100644
--- a/archivebox/core/settings.py
+++ b/archivebox/core/settings.py
@@ -235,7 +235,7 @@ SQLITE_CONNECTION_OPTIONS = {
# https://gcollazo.com/optimal-sqlite-settings-for-django/
# https://litestream.io/tips/#busy-timeout
# https://docs.djangoproject.com/en/5.1/ref/databases/#setting-pragma-options
- "timeout": CONFIG.SQLITE_TIMEOUT,
+ "timeout": CONFIG.SQLITE_BUSY_TIMEOUT / 1000,
"check_same_thread": False,
# Keep SQLite on Django's default deferred transaction mode. BEGIN
# IMMEDIATE grabs the write lock as soon as atomic() opens, which is
diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py
index fa671cee..eb072a70 100755
--- a/archivebox/crawls/models.py
+++ b/archivebox/crawls/models.py
@@ -1582,6 +1582,10 @@ class CrawlMachine(BaseStateMachine):
now = timezone.now()
self.crawl.status = Crawl.StatusChoices.SEALED
self.crawl.retry_at = None
+ # Guard: never seal a row that a concurrent writer flipped to PAUSED.
+ # Sealing is idempotent (SEALED→SEALED is a no-op rewrite), so
+ # status__in covers both the QUEUED/STARTED→SEALED transition and the
+ # rare re-entry case.
updated = self.crawl.safe_update(
{
"status": Crawl.StatusChoices.SEALED,
@@ -1589,6 +1593,13 @@ class CrawlMachine(BaseStateMachine):
"modified_at": now,
},
refresh=False,
+ extra_filter={
+ "status__in": [
+ Crawl.StatusChoices.QUEUED,
+ Crawl.StatusChoices.STARTED,
+ Crawl.StatusChoices.SEALED,
+ ],
+ },
)
if not updated:
self.crawl.refresh_from_db()
diff --git a/archivebox/services/machine_service.py b/archivebox/services/machine_service.py
index 554e7803..b91cd92e 100644
--- a/archivebox/services/machine_service.py
+++ b/archivebox/services/machine_service.py
@@ -1,10 +1,35 @@
from __future__ import annotations
+from typing import Any
+
from asgiref.sync import sync_to_async
from abx_dl.events import MachineEvent
from abx_dl.services.base import BaseService
+_BINARY_EVENT_ALLOWED_KEYS = frozenset({"ABX_INSTALL_CACHE"})
+
+
+def _is_binary_event_key(key: str) -> bool:
+ """``MachineEvent`` projector only ever writes binary-related state.
+
+ ``Machine.config`` mirrors ``ArchiveBox.conf`` so arbitrary user keys can
+ legitimately live there — but they get there through the file ↔ DB sync,
+ not through events. Letting events write arbitrary keys would let an
+ untrusted plugin overwrite security-sensitive user config (the file ↔ DB
+ mirror is a security boundary), so the projector strips anything that
+ isn't a binary path or the binary install cache.
+ """
+ if key in _BINARY_EVENT_ALLOWED_KEYS:
+ return True
+ return key.endswith("_BINARY")
+
+
+def _strip_to_binary_keys(config: dict[str, Any] | None) -> dict[str, Any]:
+ if not isinstance(config, dict):
+ return {}
+ return {key: value for key, value in config.items() if _is_binary_event_key(str(key))}
+
class MachineService(BaseService):
LISTENS_TO = [MachineEvent]
@@ -26,14 +51,15 @@ class MachineService(BaseService):
config = dict(machine.config or {})
if event.config is not None:
- config.update(_sanitize_machine_config(event.config, lib_dir=lib_dir))
+ binary_only = _strip_to_binary_keys(event.config)
+ config.update(_sanitize_machine_config(binary_only, lib_dir=lib_dir))
elif event.method == "update":
key = event.key.replace("config/", "", 1).strip()
- if key:
+ if key and _is_binary_event_key(key):
config[key] = event.value
elif event.method == "unset":
key = event.key.replace("config/", "", 1).strip()
- if key:
+ if key and _is_binary_event_key(key):
config.pop(key, None)
else:
return
diff --git a/archivebox/tests/conftest.py b/archivebox/tests/conftest.py
index 3c68654f..cd0b879a 100644
--- a/archivebox/tests/conftest.py
+++ b/archivebox/tests/conftest.py
@@ -208,6 +208,28 @@ def isolated_data_dir(tmp_path):
return data_dir
+@pytest.fixture
+def hermetic_lib_dir(tmp_path, monkeypatch):
+ """
+ Point LIB_DIR at a tmp directory so the test can write fake binaries
+ without touching the real ``~/Library/Application Support/abx/lib`` (which
+ can contain symlinks to SIP-protected system binaries on macOS).
+
+ Opt-in only: most tests should reuse the cached real LIB_DIR for speed —
+ rebuilding from scratch per-test adds ~10× overhead. Use this only when
+ the test synthesizes binary paths or validates LIB_DIR-relative behavior.
+ """
+ import archivebox.machine.models as machine_models
+
+ lib_dir = tmp_path / "lib"
+ (lib_dir / "bin").mkdir(parents=True, exist_ok=True)
+ monkeypatch.setenv("LIB_DIR", str(lib_dir))
+ monkeypatch.setenv("ABXPKG_LIB_DIR", str(lib_dir))
+ machine_models._CURRENT_MACHINE = None
+ machine_models._CURRENT_PROCESS = None
+ return lib_dir
+
+
@pytest.fixture
def initialized_archive(isolated_data_dir):
"""
diff --git a/archivebox/tests/test_cli_server.py b/archivebox/tests/test_cli_server.py
index e539d7b1..c9725c33 100644
--- a/archivebox/tests/test_cli_server.py
+++ b/archivebox/tests/test_cli_server.py
@@ -15,10 +15,10 @@ from datetime import datetime
from types import SimpleNamespace
-def test_sqlite_connections_use_explicit_30_second_busy_timeout():
+def test_sqlite_connections_use_explicit_busy_timeout():
from archivebox.core.settings import SQLITE_CONNECTION_OPTIONS
- assert SQLITE_CONNECTION_OPTIONS["OPTIONS"]["timeout"] == 30
+ assert SQLITE_CONNECTION_OPTIONS["OPTIONS"]["timeout"] == 30.0
assert "PRAGMA busy_timeout = 30000;" in SQLITE_CONNECTION_OPTIONS["OPTIONS"]["init_command"]
assert "PRAGMA journal_mode = WAL;" in SQLITE_CONNECTION_OPTIONS["OPTIONS"]["init_command"]
diff --git a/archivebox/tests/test_crawl_runner.py b/archivebox/tests/test_crawl_runner.py
index da7eada1..1aad6567 100644
--- a/archivebox/tests/test_crawl_runner.py
+++ b/archivebox/tests/test_crawl_runner.py
@@ -299,11 +299,24 @@ def test_crawl_start_event_does_not_reschedule_sealed_parent_until_explicit_requ
crawl.refresh_from_db()
snapshot.refresh_from_db()
+ # CrawlStartEvent on a sealed parent is a no-op — neither the parent nor
+ # its sealed child gets resurrected by the handler.
assert crawl.status == Crawl.StatusChoices.SEALED
- assert crawl.retry_at is None
+ assert crawl.retry_at == before
assert snapshot.status == Snapshot.StatusChoices.SEALED
assert snapshot.retry_at == before
+ # The orchestrator's seal-cleanup pass picks the sealed row up via
+ # retry_at, runs cleanup, and clears retry_at — that's how the
+ # ``retry_at != None`` invariant the handler intentionally preserves
+ # eventually drains to ``None``.
+ from archivebox.services.runner import run_due_crawl
+
+ assert run_due_crawl(crawl, lock_seconds=10) is True
+ crawl.refresh_from_db()
+ assert crawl.status == Crawl.StatusChoices.SEALED
+ assert crawl.retry_at is None
+
crawl.update_and_requeue(status=Crawl.StatusChoices.QUEUED, retry_at=timezone.now())
crawl.refresh_from_db()
assert crawl.status == Crawl.StatusChoices.QUEUED
@@ -341,7 +354,7 @@ def test_snapshot_queue_selection_is_retry_at_only_for_sealed_maintenance():
@pytest.mark.django_db(transaction=True)
-def test_machine_service_persists_only_derived_config_events(tmp_path):
+def test_machine_service_persists_only_derived_config_events(tmp_path, hermetic_lib_dir):
from abx_dl.events import MachineEvent
from abx_dl.orchestrator import create_bus
from archivebox.machine.models import Machine
@@ -350,9 +363,7 @@ def test_machine_service_persists_only_derived_config_events(tmp_path):
machine = Machine.current()
machine.config = {}
machine.save(update_fields=["config"])
- lib_dir = tmp_path / "lib"
- wget_binary = lib_dir / "bin" / "wget"
- wget_binary.parent.mkdir(parents=True)
+ wget_binary = hermetic_lib_dir / "bin" / "wget"
wget_binary.write_text("#!/bin/sh\n")
wget_binary.chmod(0o755)
@@ -400,11 +411,19 @@ def test_machine_service_persists_only_derived_config_events(tmp_path):
asyncio.run(run_test())
machine.refresh_from_db()
- assert machine.config == {}
+ # User events are dropped (handler ignores non-derived). At the event
+ # projector ``machine_service.py`` strips anything that isn't a binary
+ # path / install cache — that's the security boundary that stops plugins
+ # from rewriting arbitrary user config via events. So CHROME_USER_DATA_DIR
+ # from the derived payload is dropped; WGET_BINARY made it in (inside
+ # LIB_DIR) then the unset removed it; ABX_INSTALL_CACHE survives.
+ assert machine.config == {
+ "ABX_INSTALL_CACHE": {"wget": "2026-03-24T00:00:00+00:00"},
+ }
@pytest.mark.django_db(transaction=True)
-def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path):
+def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path, hermetic_lib_dir):
import archivebox.machine.models as machine_models
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.config.common import get_config
@@ -413,13 +432,10 @@ def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path):
from archivebox.machine.models import Machine
from archivebox.services.runner import CrawlRunner
- machine_models._CURRENT_MACHINE = None
- machine_models._CURRENT_PROCESS = None
+ resolved_lib_dir = get_config(include_machine=False).LIB_DIR
+ assert resolved_lib_dir == hermetic_lib_dir, f"LIB_DIR override not applied: {resolved_lib_dir!r} != {hermetic_lib_dir!r}"
- lib_dir = get_config(include_machine=False).LIB_DIR
- wget_binary = lib_dir / "bin" / "wget"
- wget_binary.parent.mkdir(parents=True, exist_ok=True)
- original_wget = wget_binary.read_bytes() if wget_binary.exists() else None
+ wget_binary = resolved_lib_dir / "bin" / "wget"
wget_binary.write_text("#!/bin/sh\n", encoding="utf-8")
wget_binary.chmod(0o755)
external_binary = tmp_path / "external" / "yt-dlp"
@@ -438,26 +454,30 @@ def test_load_run_state_uses_real_lib_dir_for_machine_binary_config(tmp_path):
machine.save(update_fields=["config"])
machine_models._CURRENT_MACHINE = machine
- try:
- crawl = Crawl.objects.create(
- urls="https://example.com",
- config={
- "PLUGINS": "__archivebox_test_no_plugins__",
- "CHROME_BINARY": "",
- },
- created_by_id=get_or_create_system_user_pk(),
- )
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ config={
+ "PLUGINS": "__archivebox_test_no_plugins__",
+ "CHROME_BINARY": "",
+ },
+ created_by_id=get_or_create_system_user_pk(),
+ )
- runner = CrawlRunner(crawl)
- snapshot_ids = runner.load_run_state()
- finally:
- if original_wget is None:
- wget_binary.unlink(missing_ok=True)
- else:
- wget_binary.write_bytes(original_wget)
+ runner = CrawlRunner(crawl)
+ snapshot_ids = runner.load_run_state()
- assert runner.derived_config == {"WGET_BINARY": str(wget_binary)}
- assert runner.base_config["LIB_DIR"] == lib_dir
+ # ``derived_config`` is Machine.config sanitized against LIB_DIR. Binary
+ # paths outside LIB_DIR drop out (YTDLP_BINARY → ``/tmp/...``); the
+ # ArchiveBox.conf mirror values (CHROME_ISOLATION, CHROME_USER_DATA_DIR,
+ # ABX_INSTALL_CACHE) survive so plugin hooks see the same runtime cache
+ # the user/runner persisted.
+ assert runner.derived_config == {
+ "WGET_BINARY": str(wget_binary),
+ "ABX_INSTALL_CACHE": {"wget": "2026-03-24T00:00:00+00:00"},
+ "CHROME_ISOLATION": "snapshot",
+ "CHROME_USER_DATA_DIR": "/tmp/stale-profile",
+ }
+ assert runner.base_config["LIB_DIR"] == resolved_lib_dir
assert runner.base_config["CHROME_KEEPALIVE"] is False
assert runner.selected_plugins == ["__archivebox_test_no_plugins__"]
assert Snapshot.objects.filter(id__in=snapshot_ids, crawl=crawl, url="https://example.com").count() == 1
diff --git a/archivebox/tests/test_machine_models.py b/archivebox/tests/test_machine_models.py
index 2f95a31e..eff54a06 100644
--- a/archivebox/tests/test_machine_models.py
+++ b/archivebox/tests/test_machine_models.py
@@ -157,8 +157,13 @@ class TestMachineModel:
assert result is not None
assert result.config.get("WGET_BINARY") == str(wget_path)
- def test_machine_from_jsonl_keeps_only_valid_binary_paths(self, cleanup_paths):
- """Machine.from_json() should persist only valid LIB_DIR binary paths."""
+ def test_machine_from_jsonl_drops_invalid_binary_paths_keeps_mirror(self, cleanup_paths):
+ """Machine.from_json() drops invalid binary paths but mirrors other keys.
+
+ ``Machine.config`` mirrors ``ArchiveBox.conf`` (non-binary user config
+ keys live alongside derived binary state), so non-binary keys in the
+ import survive. Only ``_BINARY`` paths get validated/dropped on import.
+ """
from archivebox.config.constants import CONSTANTS
Machine.current() # Ensure machine exists
@@ -178,7 +183,7 @@ class TestMachineModel:
assert result is not None
assert result.config.get("WGET_BINARY") == str(wget_path)
- assert "CHROMIUM_VERSION" not in result.config
+ assert result.config.get("CHROMIUM_VERSION") == "123.4.5"
assert "YTDLP_BINARY" not in result.config
def test_machine_from_jsonl_invalid(self):
@@ -186,8 +191,14 @@ class TestMachineModel:
result = Machine.from_json({"invalid": "record"})
assert result is None
- def test_machine_current_keeps_only_derived_runtime_cache(self, cleanup_paths):
- """Machine.current() should keep derived cache entries, not runtime config."""
+ def test_machine_current_drops_invalid_binary_paths_keeps_mirror(self, cleanup_paths):
+ """Machine.current() mirrors ArchiveBox.conf, only drops invalid binaries.
+
+ ``Machine.config`` is the file ↔ DB mirror of ``ArchiveBox.conf``, so
+ non-binary keys (``CHROME_ISOLATION``, ``CHROMIUM_VERSION``, etc.) are
+ preserved on read. Only ``_BINARY`` paths get validated against
+ ``LIB_DIR`` and dropped when stale/missing.
+ """
import archivebox.machine.models as models
from archivebox.config.constants import CONSTANTS
@@ -216,17 +227,26 @@ class TestMachineModel:
refreshed = Machine.current(refresh=True)
+ # Valid binary paths inside LIB_DIR survive.
assert refreshed.config.get("CHROME_BINARY") == str(chrome_path)
assert refreshed.config.get("NODE_BINARY") == str(node_path)
- assert "ABX_INSTALL_CACHE" not in refreshed.config
- assert "CHROME_ISOLATION" not in refreshed.config
- assert "CHROME_USER_DATA_DIR" not in refreshed.config
- assert "CHROMIUM_VERSION" not in refreshed.config
+ # Non-binary mirror keys survive — they belong to ArchiveBox.conf.
+ assert refreshed.config.get("ABX_INSTALL_CACHE") == {"wget": "2026-03-24T00:00:00+00:00"}
+ assert refreshed.config.get("CHROME_ISOLATION") == "snapshot"
+ assert refreshed.config.get("CHROME_USER_DATA_DIR") == "/tmp/profile"
+ assert refreshed.config.get("CHROMIUM_VERSION") == "123.4.5"
+ # Stale binary paths get dropped: YTDLP_BINARY outside LIB_DIR,
+ # WGET_BINARY path doesn't exist.
assert "YTDLP_BINARY" not in refreshed.config
assert "WGET_BINARY" not in refreshed.config
def test_get_config_auto_applies_current_machine_config(self, cleanup_paths):
- """get_config() should include sanitized Machine.current() config by default."""
+ """get_config() applies the full Machine.config mirror as scope overrides.
+
+ ``Machine.config`` mirrors ``ArchiveBox.conf``, so non-binary user keys
+ like ``CHROME_ISOLATION`` flow through into the merged ``get_config()``
+ result alongside validated binary paths.
+ """
import archivebox.machine.models as models
from archivebox.config.common import get_config
@@ -247,7 +267,7 @@ class TestMachineModel:
config = get_config()
assert config.CHROME_BINARY == str(chrome_path)
- assert config.CHROME_ISOLATION == "crawl"
+ assert config.CHROME_ISOLATION == "snapshot"
def test_machine_manager_current(self):
"""Machine.objects.current() should return current machine."""
diff --git a/docs/.mkdocs.unused/gen_docs_refs.py b/docs/.mkdocs.unused/gen_docs_refs.py
index 9e43f32e..8447015f 100644
--- a/docs/.mkdocs.unused/gen_docs_refs.py
+++ b/docs/.mkdocs.unused/gen_docs_refs.py
@@ -8,18 +8,23 @@ nav = mkdocs_gen_files.Nav()
mod_symbol = ''
packages_dir = Path(__file__).parent
-doc_root = packages_dir / 'docs'
+doc_root = packages_dir / "docs"
-for path in sorted((packages_dir / 'archivebox').rglob("*.py")):
+for path in sorted((packages_dir / "archivebox").rglob("*.py")):
module_path = path.relative_to(packages_dir).with_suffix("")
doc_path = path.relative_to(packages_dir).with_suffix(".md")
- full_doc_path = doc_root / 'reference' / doc_path
-
- if "management" in str(module_path) or "vendor" in str(module_path) or 'machine' in str(module_path) or 'migrations' in str(module_path) or 'plugins' in str(module_path):
+ full_doc_path = doc_root / "reference" / doc_path
+
+ if (
+ "management" in str(module_path)
+ or "vendor" in str(module_path)
+ or "machine" in str(module_path)
+ or "migrations" in str(module_path)
+ or "plugins" in str(module_path)
+ ):
continue
parts = tuple(module_path.parts)
-
if parts[-1] == "__init__":
parts = parts[:-1]
@@ -27,9 +32,9 @@ for path in sorted((packages_dir / 'archivebox').rglob("*.py")):
full_doc_path = full_doc_path.with_name("index.md")
elif parts[-1].startswith("_"):
continue
-
+
full_doc_path = full_doc_path.relative_to(packages_dir)
-
+
# import ipdb; ipdb.set_trace()
nav_parts = [f"{mod_symbol} {part}" for part in parts]
diff --git a/docs/ArchiveBox-Architecture-Diagrams.md b/docs/ArchiveBox-Architecture-Diagrams.md
index 7ae3f69c..d0d44a74 100644
--- a/docs/ArchiveBox-Architecture-Diagrams.md
+++ b/docs/ArchiveBox-Architecture-Diagrams.md
@@ -198,4 +198,3 @@ stateDiagram-v2
retry_at = None
end note
```
-
diff --git a/docs/Changelog.md b/docs/Changelog.md
index 8745a3a4..7e26cc00 100644
--- a/docs/Changelog.md
+++ b/docs/Changelog.md
@@ -119,4 +119,4 @@
---
- v0.0.0 released: created Pocket Archive Stream 2017/05/05
-
\ No newline at end of file
+
diff --git a/docs/Donations.md b/docs/Donations.md
index e418a99b..7066430c 100644
--- a/docs/Donations.md
+++ b/docs/Donations.md
@@ -20,4 +20,4 @@
-If you have any questions or want to partner with this project, contact me at: `donations-hello` `@` `archivebox` `.` `io`.
\ No newline at end of file
+If you have any questions or want to partner with this project, contact me at: `donations-hello` `@` `archivebox` `.` `io`.
diff --git a/docs/Roadmap.md b/docs/Roadmap.md
index defc4759..651f5116 100644
--- a/docs/Roadmap.md
+++ b/docs/Roadmap.md
@@ -234,4 +234,4 @@ And others we're considering for the future:
- https://github.com/manga-download/hakuneko
- https://github.com/cancerian0684/dli-downloader (Digital Library of India ebook downloader)
- https://github.com/tusharbabbar/gaana-dl (gaana.com bollywood song downloader)
-- https://github.com/rebane2001/matterport-dl (stale? virtual house tour downloader)
\ No newline at end of file
+- https://github.com/rebane2001/matterport-dl (stale? virtual house tour downloader)
diff --git a/docs/Upgrading-or-Merging-Archives.md b/docs/Upgrading-or-Merging-Archives.md
index 31acbb9b..16b6a211 100644
--- a/docs/Upgrading-or-Merging-Archives.md
+++ b/docs/Upgrading-or-Merging-Archives.md
@@ -4,4 +4,4 @@ Moved to:
- [[Upgrading]]
- [[Merging Collections]]
-- [Database Troubleshooting](./Troubleshooting#database)
\ No newline at end of file
+- [Database Troubleshooting](./Troubleshooting#database)
diff --git a/docs/Upgrading.md b/docs/Upgrading.md
index 0bb19bba..a4da2ed2 100644
--- a/docs/Upgrading.md
+++ b/docs/Upgrading.md
@@ -34,7 +34,7 @@ You can specify exact versions with pip like so: `pip install archivebox==0.6.3`
**ℹ️ How it works internally:**
-The same command is used for initializing a new archive and upgrading an existing one. `archivebox init` is indempotent and safely be run multiple times. Running it will ensure your collection is on the latest version and all the files are in their correct locations. `archivebox status` can be used to check for orphan/corrupted snapshots or invalid index data.
+The same command is used for initializing a new archive and upgrading an existing one. `archivebox init` is idempotent and safely be run multiple times. Running it will ensure your collection is on the latest version and all the files are in their correct locations. `archivebox status` can be used to check for orphan/corrupted snapshots or invalid index data.
There are three main areas on disk that ArchiveBox modifies during upgrades:
- `index.sqlite3` contains the SQLite3 DB index that gets upgraded automatically by Django based on the changes in [`archivebox/core/models.py`](https://github.com/ArchiveBox/ArchiveBox/blob/dev/archivebox/core/models.py).
diff --git a/docs/Web-Archiving-Community.md b/docs/Web-Archiving-Community.md
index 8f89f38b..f3e7dbc9 100644
--- a/docs/Web-Archiving-Community.md
+++ b/docs/Web-Archiving-Community.md
@@ -426,7 +426,7 @@ Find your local archiving group in the list and see how you can contribute!
- [Software Preservation Network](https://www.softwarepreservationnetwork.org/about/) (International)
- [ITHAKA](https://www.ithaka.org/content/our-mission), [Portico](https://www.portico.org/why-portico/), [JSTOR](https://www.jstor.org/), [ARTSTOR](http://www.artstor.org/), [S+R](https://sr.ithaka.org/our-work/collections-and-preservation/) (USA)
- [Archives and Records Association](https://www2.archivists.org/assoc-orgs/archives-and-records-association-united-kingdom-ireland) (UK & Ireland)
-- [Arkivrådet AAS](http://www.arkivradet.se/) (Sweden)
+- [Arkivrådet](http://www.arkivradet.se/) (Sweden)
- [Asociación Española de Archiveros, Bibliotecarios, Museologos y Documentalistas (ANABAD)](https://www2.archivists.org/assoc-orgs/asociaci%C3%B3n-espa%C3%B1ola-de-archiveros-bibliotecarios-museologos-y-documentalistas-anabad) (Spain)
- [Associação dos Arquivistas Brasileiros (AAB)](https://www2.archivists.org/assoc-orgs/associacao-dos-arquivistas-brasileiros-aab) (Brazil)
- [Associação Portuguesa de Bibliotecários, Archivistas e Documentalistas (BAD)](https://www2.archivists.org/assoc-orgs/associacao-portuguesa-de-bibliotecarios-archivistas-e-documentalistas-bad) (Portugal)
diff --git a/docs/conf.py b/docs/conf.py
index 37397092..2f8708f1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -10,45 +10,48 @@
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
+import datetime
+import os
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent))
-sys.path.append(str(Path(__file__).parent.parent / 'archivebox'))
+sys.path.append(str(Path(__file__).parent.parent / "archivebox"))
# -- Project information -----------------------------------------------------
-project = 'ArchiveBox'
-import datetime
-copyright = f'{datetime.date.today().year} ArchiveBox'
-author = 'Nick Sweeting'
-github_url = 'https://github.com/ArchiveBox/ArchiveBox'
-github_doc_root = 'https://github.com/ArchiveBox/docs/tree/master/' # docs repo uses master branch
-github_view_style = 'blob'
-language = 'en'
+project = "ArchiveBox"
+
+copyright = f"{datetime.date.today().year} ArchiveBox"
+author = "Nick Sweeting"
+github_url = "https://github.com/ArchiveBox/ArchiveBox"
+github_doc_root = "https://github.com/ArchiveBox/docs/tree/master/" # docs repo uses master branch
+github_view_style = "blob"
+language = "en"
# The full version, including alpha/beta/rc tags
-release = (Path(__file__).parent.parent / 'pyproject.toml').read_text().split('version = ', 1)[-1].split('\n', 1)[0].strip('"').strip("'")
+release = (Path(__file__).parent.parent / "pyproject.toml").read_text().split("version = ", 1)[-1].split("\n", 1)[0].strip('"').strip("'")
tag = release
# 0.8.5 -> v0.8.5
if release[0].isdigit():
- tag = f"v{release}" # .split('rc')[0]
+ tag = f"v{release}" # .split('rc')[0]
# Detect if this is a dev/pre-release build using PEP 440 parsing.
# A version like "0.9.10" with no suffix is stable.
# A version like "0.9.10rc1", "0.9.10.dev1", "0.9.10a1" is pre-release.
# When you release 0.9.0 final (no suffix), it auto-becomes stable.
-import os
+
try:
from packaging.version import Version
+
is_dev = Version(release).is_prerelease or Version(release).is_devrelease
except Exception:
# fallback if packaging is not installed
- is_dev = any(label in release for label in ('dev', 'rc', 'alpha', 'beta'))
+ is_dev = any(label in release for label in ("dev", "rc", "alpha", "beta"))
# RTD "latest" always builds from the default branch = dev docs
-rtd_version = os.environ.get('READTHEDOCS_VERSION', '')
-if rtd_version in ('latest', 'dev', 'main', 'master'):
+rtd_version = os.environ.get("READTHEDOCS_VERSION", "")
+if rtd_version in ("latest", "dev", "main", "master"):
is_dev = True
# -- General configuration ---------------------------------------------------
@@ -57,15 +60,15 @@ if rtd_version in ('latest', 'dev', 'main', 'master'):
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- 'sphinx.ext.autodoc',
- 'sphinx.ext.napoleon',
- 'sphinx.ext.linkcode',
- 'sphinx.ext.autosummary',
+ "sphinx.ext.autodoc",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.linkcode",
+ "sphinx.ext.autosummary",
# 'sphinx.ext.graphviz',
# 'sphinx.ext.inheritance_diagram'
- 'myst_parser', # pip install myst-parser
- 'autodoc2',
- 'sphinxcontrib.mermaid', # pip install sphinxcontrib-mermaid
+ "myst_parser", # pip install myst-parser
+ "autodoc2",
+ "sphinxcontrib.mermaid", # pip install sphinxcontrib-mermaid
# 'recommonmark',
]
autodoc2_packages = [
@@ -73,46 +76,46 @@ autodoc2_packages = [
"path": "../archivebox",
"module": "archivebox",
"exclude_dirs": [
- '__pycache__',
- 'migrations',
- 'vendor',
- 'typings',
- 'templates',
- 'static',
- 'tests',
- 'tmp',
+ "__pycache__",
+ "migrations",
+ "vendor",
+ "typings",
+ "templates",
+ "static",
+ "tests",
+ "tmp",
],
"exclude_files": [
- 'tests.py',
- 'conftest.py',
- 'fixtures.py',
+ "tests.py",
+ "conftest.py",
+ "fixtures.py",
],
},
]
-autodoc2_output_dir = 'apidocs'
+autodoc2_output_dir = "apidocs"
autodoc2_render_plugin = "myst"
autodoc2_skip_module_regexes = [
- r'.*migrations.*',
- r'.*vendor.*',
- r'.*\.tests($|\..*)',
- r'.*\.conftest$',
- r'.*\.fixtures$',
+ r".*migrations.*",
+ r".*vendor.*",
+ r".*\.tests($|\..*)",
+ r".*\.conftest$",
+ r".*\.fixtures$",
]
# autodoc2_hidden_objects = ['inherited', 'dunder']
autodoc2_hidden_regexes = [
- r'.*__package__',
+ r".*__package__",
]
-myst_enable_extensions = ['linkify'] # pip install linkify-it-py
-myst_fence_as_directive = ['mermaid'] # render ```mermaid blocks via sphinxcontrib-mermaid
+myst_enable_extensions = ["linkify"] # pip install linkify-it-py
+myst_fence_as_directive = ["mermaid"] # render ```mermaid blocks via sphinxcontrib-mermaid
source_suffix = {
- '.rst': 'restructuredtext',
- '.txt': 'markdown',
- '.md': 'markdown',
+ ".rst": "restructuredtext",
+ ".txt": "markdown",
+ ".md": "markdown",
}
-master_doc = 'index'
+master_doc = "index"
napoleon_google_docstring = True
napoleon_use_param = True
napoleon_use_ivar = False
@@ -120,30 +123,30 @@ napoleon_use_rtype = True
napoleon_include_special_with_doc = False
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = [
- '_build',
- '**/Thumbs.db',
- '**/.DS_Store',
- 'data*',
- 'requirements.txt',
- '**/requirements.txt',
- '**/tests/**',
- '**/templates/**',
- '**/migrations/**',
- '_Sidebar.md',
- '_Footer.md',
+ "_build",
+ "**/Thumbs.db",
+ "**/.DS_Store",
+ "data*",
+ "requirements.txt",
+ "**/requirements.txt",
+ "**/tests/**",
+ "**/templates/**",
+ "**/migrations/**",
+ "_Sidebar.md",
+ "_Footer.md",
]
suppress_warnings = [
- 'myst.header', # non-consecutive header levels (common in wiki markdown)
- 'myst.xref_missing', # cross-reference targets from wiki-style links
- 'myst.xref_ambiguous', # ambiguous cross-references across modules
- 'autodoc2.dup_item', # duplicate items from Django model inheritance
+ "myst.header", # non-consecutive header levels (common in wiki markdown)
+ "myst.xref_missing", # cross-reference targets from wiki-style links
+ "myst.xref_ambiguous", # ambiguous cross-references across modules
+ "autodoc2.dup_item", # duplicate items from Django model inheritance
]
@@ -152,15 +155,15 @@ suppress_warnings = [
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
-html_logo = 'logo.png'
-html_theme = 'sphinx_rtd_theme'
+html_logo = "logo.png"
+html_theme = "sphinx_rtd_theme"
html_theme_options = {
- 'navigation_depth': 5,
- 'collapse_navigation': False,
- 'sticky_navigation': True,
- 'version_selector': True,
- 'language_selector': False,
- 'style_external_links': True,
+ "navigation_depth": 5,
+ "collapse_navigation": False,
+ "sticky_navigation": True,
+ "version_selector": True,
+ "language_selector": False,
+ "style_external_links": True,
}
html_context = {
"display_github": True,
@@ -176,58 +179,67 @@ html_context = {
html_show_sphinx = False
# Display the version prominently so users know which docs they're reading
-version = release # short X.Y version shown in sidebar
+version = release # short X.Y version shown in sidebar
# release is already set above # full version with alpha/beta/rc tags
texinfo_documents = [
- (master_doc, 'archivebox', 'archivebox Documentation', author, 'archivebox', 'The open-source self-hosted internet archive.', 'Miscellaneous'),
+ (
+ master_doc,
+ "archivebox",
+ "archivebox Documentation",
+ author,
+ "archivebox",
+ "The open-source self-hosted internet archive.",
+ "Miscellaneous",
+ ),
]
-autodoc_default_flags = ['members']
-autodoc_member_order = 'bysource'
+autodoc_default_flags = ["members"]
+autodoc_member_order = "bysource"
autosummary_generate = True
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
man_pages = [
- (master_doc, 'archivebox', 'archivebox Documentation', [author], 1)
+ (master_doc, "archivebox", "archivebox Documentation", [author], 1),
]
+
def linkcode_resolve(domain, info):
"""
Calculate link to source code on Github
Docs: https://www.sphinx-doc.org/en/master/usage/extensions/linkcode.html
"""
- module_name = str(info['module'] or '')
- package_name = module_name.split('.', 1)[0] # archivebox
- submodule_name = module_name.split(f'{package_name}', 1)[-1].strip('.') # core.models
- symbol_name = str(info['fullname'] or '') # Crawl.abid_ts_src
- full_name = f'{package_name}.{submodule_name}.{symbol_name}'.replace('..', '.') # archivebox.core.models.Crawl.abid_ts_src
- fallback_url = f'https://github.com/search?type=code&q=repo%3AArchiveBox%2FArchiveBox%20{full_name.replace(".", "%20")}'
+ module_name = str(info["module"] or "")
+ package_name = module_name.split(".", 1)[0] # archivebox
+ submodule_name = module_name.split(f"{package_name}", 1)[-1].strip(".") # core.models
+ symbol_name = str(info["fullname"] or "") # Crawl.abid_ts_src
+ full_name = f"{package_name}.{submodule_name}.{symbol_name}".replace("..", ".") # archivebox.core.models.Crawl.abid_ts_src
+ fallback_url = f"https://github.com/search?type=code&q=repo%3AArchiveBox%2FArchiveBox%20{full_name.replace('.', '%20')}"
# 'archivebox.core.models.Crawl' -> archivebox/core/models.py
- file_path = f'{package_name}/{submodule_name.replace(".", "/")}.py' # archivebox/core/models.py
-
+ file_path = f"{package_name}/{submodule_name.replace('.', '/')}.py" # archivebox/core/models.py
+
# correct for any extra / or .py
- file_path = file_path.strip('/').strip('.py') + '.py'
-
+ file_path = file_path.strip("/").strip(".py") + ".py"
+
# fallback to using Github search instead if URL doesn't look like a valid file path
- if not file_path.startswith('archivebox/'):
+ if not file_path.startswith("archivebox/"):
return fallback_url
- if '//' in file_path:
+ if "//" in file_path:
return fallback_url
- if file_path.count('.py') > 1:
+ if file_path.count(".py") > 1:
return fallback_url
-
+
# correct for archivebox/cli.py -> archivebox/cli/__init__.py
- init_path = f'{package_name}/{submodule_name.replace(".", "/")}/__init__.py'
- if not Path(f'../{file_path}').is_file():
- if Path(f'../{init_path}').is_file():
+ init_path = f"{package_name}/{submodule_name.replace('.', '/')}/__init__.py"
+ if not Path(f"../{file_path}").is_file():
+ if Path(f"../{init_path}").is_file():
file_path = init_path
else:
return fallback_url
diff --git a/etc/package.json b/etc/package.json
index 85b94157..d7b2d596 100644
--- a/etc/package.json
+++ b/etc/package.json
@@ -1,6 +1,6 @@
{
"name": "archivebox",
- "version": "0.9.33rc56",
+ "version": "0.9.33rc58",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
diff --git a/pyproject.toml b/pyproject.toml
index 8919918e..ed0696b7 100755
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "archivebox"
-version = "0.9.33rc56"
+version = "0.9.33rc58"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus==2.5.8", # EventBus API
- "abxpkg>=1.11.81", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
- "abx-plugins>=1.11.90", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
- "abx-dl>=1.11.90", # shared ArchiveBox downloader package with blocking install preflight
+ "abxpkg>=1.11.83", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
+ "abx-plugins>=1.11.92", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
+ "abx-dl>=1.11.92", # shared ArchiveBox downloader package with blocking install preflight
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]