Fix CI runtime resolution and host policy regressions

This commit is contained in:
Nick Sweeting 2026-07-25 18:35:25 -07:00
parent d312c25531
commit 8254f962ba
No known key found for this signature in database
16 changed files with 144 additions and 63 deletions

View File

@ -194,6 +194,12 @@ def _coerce_from_str_dict(file_config: dict[str, str]) -> dict[str, Any]:
decoded[key] = json.loads(raw)
except (TypeError, ValueError):
continue
if not str(decoded.get("BASE_URL") or "").strip():
from archivebox.config.common import base_url_from_legacy_server_config
legacy_base_url = base_url_from_legacy_server_config(decoded)
if legacy_base_url:
decoded["BASE_URL"] = legacy_base_url
return decoded

View File

@ -1245,6 +1245,10 @@ def get_config(
)
config = ArchiveBoxConfig.model_validate(config_data)
if config.SERVER_SECURITY_MODE == "auto":
base_host = (urlparse(config.BASE_URL if "://" in config.BASE_URL else f"//{config.BASE_URL}").hostname or "").lower()
if base_host.endswith(".localhost"):
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])

View File

@ -413,6 +413,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
"cmd_version",
"pwd",
"cmd_str",
"admin_actions",
"snapshot_info",
"tags_str",
"created_at",
@ -438,7 +439,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
(
"Snapshot",
{
"fields": ("snapshot", "snapshot_info", "tags_str"),
"fields": ("snapshot", "snapshot_info", "tags_str", "admin_actions"),
"classes": ("card", "wide"),
},
),
@ -630,6 +631,38 @@ class ArchiveResultAdmin(BaseModelAdmin):
self.get_output_zip_url(result),
)
@admin.display(description="")
def admin_actions(self, result):
return format_html(
"""
<div style="display: flex; flex-wrap: wrap; gap: 12px; align-items: center;">
<a class="btn" style="display: inline-flex; align-items: center; gap: 6px; padding: 10px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; color: #334155; text-decoration: none; font-size: 14px; font-weight: 500;"
href="{}">
📄 View Output
</a>
<a class="btn" style="display: inline-flex; align-items: center; gap: 6px; padding: 10px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; color: #334155; text-decoration: none; font-size: 14px; font-weight: 500;"
href="{}">
📁 Output files
</a>
<a class="btn archivebox-zip-button" style="display: inline-flex; align-items: center; gap: 6px; padding: 10px 16px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; color: #1d4ed8; text-decoration: none; font-size: 14px; font-weight: 500;"
href="{}"
data-loading-label="Preparing..."
onclick="return window.archiveboxHandleZipClick(this, event);">
<span class="archivebox-zip-spinner" aria-hidden="true"></span>
<span class="archivebox-zip-label"> Download Zip</span>
</a>
<a class="btn" style="display: inline-flex; align-items: center; gap: 6px; padding: 10px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; color: #334155; text-decoration: none; font-size: 14px; font-weight: 500;"
href="{}">
🗂 Snapshot
</a>
</div>
""",
self.get_output_view_url(result),
self.get_output_files_url(result),
self.get_output_zip_url(result),
self.get_snapshot_view_url(result),
)
@admin.display(
description="Snapshot",
ordering="snapshot__url",

View File

@ -89,9 +89,10 @@ def AdminCookieIsolationMiddleware(get_response):
response = get_response(request)
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
if config is None or config.SERVER_SECURITY_MODE == "auto":
from archivebox.config.common import get_request_config
config = get_request_config(request, resolve_plugins=False)
if not config.USES_SUBDOMAIN_ROUTING:
return response
@ -168,9 +169,17 @@ def ServerSecurityModeMiddleware(get_response):
def middleware(request):
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
if config is None or config.SERVER_SECURITY_MODE == "auto":
base_config = config or get_config(resolve_plugins=False)
if base_config.SERVER_SECURITY_MODE == "auto" and request.method.upper() not in allowed_methods:
request_host, _request_port = split_host_port((request.get_host() or "").lower())
base_host, _base_port = split_host_port(get_base_host(config=base_config))
if base_host and request_host != base_host:
return HttpResponseForbidden("ArchiveBox is running with the control plane disabled on this host.")
from archivebox.config.common import get_request_config
config = get_request_config(request, resolve_plugins=False)
if config.CONTROL_PLANE_ENABLED:
return get_response(request)

View File

@ -348,6 +348,12 @@ if not _secret_persisted:
logger.debug("Unable to persist generated SECRET_KEY", exc_info=True)
ALLOWED_HOSTS = [host.strip() for host in CONFIG.ALLOWED_HOSTS.split(",") if host.strip()]
if (CONFIG.SERVER_SECURITY_MODE == "auto" or CONFIG.USES_SUBDOMAIN_ROUTING) and "*" not in ALLOWED_HOSTS:
# ArchiveBox owns the effective host policy at request time in auto and
# subdomain modes: canonical hosts keep the normal app surface, while
# alternate hosts are downgraded/rejected by get_request_config()/middleware.
# Django has to admit the Host header first for that existing boundary to run.
ALLOWED_HOSTS.append("*")
CSRF_TRUSTED_ORIGINS = list({origin.strip() for origin in CONFIG.CSRF_TRUSTED_ORIGINS.split(",") if origin.strip()})
admin_base_url = normalize_base_url(get_admin_base_url())

View File

@ -912,7 +912,7 @@ def _plugin_full_preview_response(
response.headers["Content-Security-Policy"] = (
"default-src 'self' data: blob:; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
"style-src 'self' 'unsafe-inline' data: blob:; "
"style-src 'unsafe-inline' data: blob: 'self'; "
"connect-src 'self' data: blob:; "
"img-src 'self' data: blob:; "
"media-src 'self' data: blob:; "

View File

@ -238,9 +238,18 @@ class Machine(ModelWithHealthStats):
try:
_CURRENT_MACHINE = cls.objects.get(guid=host_guid)
except cls.DoesNotExist:
config = {}
try:
from archivebox.config.collection import _coerce_from_str_dict, _load_file_config_dict
file_config, _file_mtime = _load_file_config_dict()
config = _coerce_from_str_dict(file_config)
except Exception:
config = {}
_CURRENT_MACHINE = cls.objects.create(
guid=host_guid,
hostname=socket.gethostname(),
config=config,
**get_os_info(),
**get_vm_info(),
stats=get_host_stats(),

View File

@ -44,6 +44,7 @@ __package__ = "archivebox.plugins"
import json
import os
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard, runtime_checkable
@ -326,10 +327,11 @@ def run_hook(
if not script.is_file():
raise FileNotFoundError(f"Hook script not found: {script}")
# Python hooks carry their runtime contract in the shebang
# (usually `abxpkg run --script python3`), so execute them directly.
# For shell/JS hooks we still dispatch through the conventional
# interpreter because those hooks do not need per-script Python env setup.
# ArchiveBox resolves hook dependencies before execution and exports the
# resolved *_BINARY env values below. Run Python hooks in the current Python
# environment so per-snapshot hook execution does not repeatedly pay the
# abxpkg script bootstrap cost from the standalone shebang.
# Shell/JS hooks still dispatch through abxpkg-resolved interpreters.
ext = script.suffix.lower()
if ext == ".sh":
bash_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "bash"
@ -337,7 +339,7 @@ def run_hook(
raise RuntimeError(f"Bash must be resolved by abxpkg into {bash_projection}")
cmd = [str(bash_projection), str(script)]
elif ext == ".py":
cmd = [str(script)]
cmd = [sys.executable, str(script)]
elif ext == ".js":
node_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "node"
if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK):
@ -377,7 +379,6 @@ def run_hook(
env.pop("PLUGINS", None)
env["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
env["LIBRARY_VERSION"] = VERSION
env.setdefault("ABXPKG_FAST_SCRIPT", "1")
env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID))
snap_dir = hook_config.get("SNAP_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.SNAPSHOTS_DIR_NAME)
crawl_dir = hook_config.get("CRAWL_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.CRAWLS_DIR_NAME)

View File

@ -16,12 +16,13 @@ def search_backend_env(config: dict[str, Any] | None = None, **config_kwargs: An
config = config or get_config(**config_kwargs)
updates = {}
for key, value in config.items():
if not str(key).startswith("SEARCH_BACKEND_"):
key = str(key)
if not (key.startswith("SEARCH_BACKEND_") or key.endswith("_BINARY")):
continue
if value is None:
continue
if isinstance(value, (str, int, float, bool, os.PathLike)):
updates[str(key)] = str(value)
updates[key] = str(value)
previous = {key: os.environ.get(key) for key in updates}
os.environ.update(updates)
try:

View File

@ -1665,7 +1665,7 @@
{% endif %}
<br/>
<div class="external-links">
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse the full SNAP_DIR for this snapshot" target="_blank">📁 Files</a>
<a href="{% snapshot_base_url snapshot %}/?files=1" title="Browse the full SNAP_DIR for this snapshot" target="_blank">📁 See all files...</a>
<span class="external-links-separator">|</span>
<a href="https://web.archive.org/web/{{url}}" title="Search for a copy of the URL saved in Archive.org" target="_blank" rel="noreferrer">🏛️ Archive.org</a>
<!--<a href="https://archive.md/{{url}}" title="Search for a copy of the URL saved in Archive.today" target="_blank" rel="noreferrer">Archive.today</a> &nbsp;|&nbsp; -->

View File

@ -234,11 +234,11 @@ def test_server_help_lists_runtime_options(initialized_archive):
assert "--reload" in result.stdout
def test_runner_worker_uses_abxpkg_projected_archivebox():
from archivebox.workers.supervisord_util import RUNNER_WORKER, resolve_env_binary
def test_runner_worker_uses_active_archivebox_module():
from archivebox.workers.supervisord_util import RUNNER_WORKER, archivebox_cmd
worker = RUNNER_WORKER()
assert shlex.split(worker["command"]) == [str(resolve_env_binary("archivebox")), "run", "--daemon"]
assert shlex.split(worker["command"]) == archivebox_cmd("run", "--daemon")
assert worker["autorestart"] == "true"
assert 'ARCHIVEBOX_RUNNER_DAEMON="1"' in worker["environment"]
@ -252,26 +252,20 @@ def test_daphne_worker_uses_default_application_close_timeout():
assert "--application-close-timeout=0" not in command
def test_reload_workers_use_abxpkg_projected_archivebox():
from archivebox.workers.supervisord_util import RUNNER_WATCH_WORKER, RUNSERVER_WORKER, resolve_env_binary
def test_reload_workers_use_active_archivebox_module():
from archivebox.workers.supervisord_util import RUNNER_WATCH_WORKER, RUNSERVER_WORKER, archivebox_cmd
runserver = RUNSERVER_WORKER("127.0.0.1", "8000", reload=True)
watcher = RUNNER_WATCH_WORKER("http://127.0.0.1:8000")
archivebox_binary = resolve_env_binary("archivebox")
assert runserver["name"] == "worker_runserver"
assert shlex.split(runserver["command"]) == [str(archivebox_binary), "manage", "runserver", "127.0.0.1:8000"]
assert shlex.split(runserver["command"]) == archivebox_cmd("manage", "runserver", "127.0.0.1:8000")
assert 'ARCHIVEBOX_RUNSERVER="1"' in runserver["environment"]
assert 'ARCHIVEBOX_AUTORELOAD="1"' in runserver["environment"]
assert 'ARCHIVEBOX_RUNSERVER_BIND_URL="http://127.0.0.1:8000"' in runserver["environment"]
assert watcher["name"] == "worker_runner_watch"
assert shlex.split(watcher["command"]) == [
str(archivebox_binary),
"manage",
"runner_watch",
"--bind-url=http://127.0.0.1:8000",
]
assert shlex.split(watcher["command"]) == archivebox_cmd("manage", "runner_watch", "--bind-url=http://127.0.0.1:8000")
def test_server_daemon_starts_real_plugin_owned_sonic_worker(initialized_archive, archivebox_daemon_server):

View File

@ -13,6 +13,7 @@ import json
import hashlib
import os
import subprocess
import sys
from importlib.resources import files
from pathlib import Path
@ -626,14 +627,14 @@ def test_run_hook_exports_singular_node_modules_dir_with_colon_node_path(tmp_pat
@pytest.mark.django_db(transaction=True)
def test_run_hook_executes_python_hooks_through_script_shebang(tmp_path):
"""Python hooks must execute through their abxpkg script header."""
def test_run_hook_executes_python_hooks_with_resolved_runtime_env(tmp_path):
"""ArchiveBox-run Python hooks use the active interpreter and resolved env."""
from archivebox.plugins.hooks import run_hook
snap_dir = tmp_path / "snapshot"
output_dir = snap_dir / "hashes"
output_dir.mkdir(parents=True)
(snap_dir / "source.txt").write_text("real shebang hook input", encoding="utf-8")
(snap_dir / "source.txt").write_text("real runtime hook input", encoding="utf-8")
hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py")))
process = run_hook(
hook_path,
@ -643,18 +644,18 @@ def test_run_hook_executes_python_hooks_through_script_shebang(tmp_path):
"SNAP_DIR": str(snap_dir),
},
timeout=30,
url="https://example.com/shebang",
url="https://example.com/runtime",
)
process.refresh_from_db()
assert process.cmd[0] == str(hook_path)
assert process.cmd[:2] == [sys.executable, str(hook_path)]
assert process.exit_code == 0, process.stderr
assert process.env["ABXPKG_FAST_SCRIPT"] == "1"
assert process.env["SNAP_DIR"] == str(snap_dir)
records = process.parse_records_from_text(process.stdout)
source_hash = hashlib.sha256(b"real shebang hook input").hexdigest()
source_hash = hashlib.sha256(b"real runtime hook input").hexdigest()
assert records == [{"type": "ArchiveResult", "status": "succeeded", "output_str": f"0.0MB {source_hash[:12]}"}]
hashes = json.loads((output_dir / "hashes.json").read_text())
source = hashes["files"][0]
assert source["path"] == "source.txt"
assert source["size"] == len("real shebang hook input")
assert source["size"] == len("real runtime hook input")
assert source["hash"] == source_hash

View File

@ -110,7 +110,9 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
"SEARCH_BACKEND_SONIC_HOST_NAME": "sonic",
"SEARCH_BACKEND_SONIC_PORT": 1491,
"SEARCH_BACKEND_SONIC_PASSWORD": "SecretPassword",
"RIPGREP_BINARY": tmp_path / "env" / "bin" / "rg",
"IGNORED_NONE_VALUE": None,
"UNRELATED_BINARY_NAME": "rg",
},
)
@ -120,7 +122,9 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
assert os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] == "sonic"
assert os.environ["SEARCH_BACKEND_SONIC_PORT"] == "1491"
assert os.environ["SEARCH_BACKEND_SONIC_PASSWORD"] == "SecretPassword"
assert os.environ["RIPGREP_BINARY"] == str(tmp_path / "env" / "bin" / "rg")
assert "IGNORED_NONE_VALUE" not in os.environ
assert "UNRELATED_BINARY_NAME" not in os.environ
assert os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] == "old-host"
finally:

View File

@ -48,11 +48,19 @@ def _shell_join(args: list[str]) -> str:
return shlex.join(args)
def archivebox_cmd(*args: str) -> list[str]:
executable = Path(sys.argv[0]).resolve() if sys.argv and sys.argv[0] else None
if executable and executable.name == "archivebox" and executable.is_file() and os.access(executable, os.X_OK):
return [str(executable), *args]
return [sys.executable, "-m", "archivebox", *args]
def resolve_env_binary(name: str) -> Path:
from abxpkg import EnvProvider
from archivebox.config.common import get_config
env_root = get_config().ABXPKG_LIB_DIR / "env"
lib_dir = Path(os.environ.get("ABXPKG_LIB_DIR") or get_config().ABXPKG_LIB_DIR)
env_root = lib_dir / "env"
provider = EnvProvider(install_root=env_root, PATH=os.environ["PATH"])
if name == "daphne":
from importlib.metadata import version
@ -179,7 +187,7 @@ def _stop_older_supervisord_processes(*, current_pid: int, current_started_at: f
def RUNNER_WORKER():
return {
"name": "worker_runner",
"command": _shell_join([str(resolve_env_binary("archivebox")), "run", "--daemon"]),
"command": _shell_join(archivebox_cmd("run", "--daemon")),
"autostart": "false",
"autorestart": "true",
"environment": 'PYTHONUNBUFFERED="1",COLUMNS="200",ARCHIVEBOX_RUNNER_DAEMON="1"',
@ -194,7 +202,7 @@ def RUNNER_WORKER():
RUNNER_ONCE_WORKER = lambda args, name="worker_runner_once": {
**RUNNER_WORKER(),
"name": name,
"command": _shell_join([str(resolve_env_binary("archivebox")), "run", "--no-stdin", *args]),
"command": _shell_join(archivebox_cmd("run", "--no-stdin", *args)),
"environment": 'PYTHONUNBUFFERED="1",COLUMNS="200"',
"autorestart": "false",
"stopwaitsecs": "1",
@ -203,7 +211,7 @@ RUNNER_ONCE_WORKER = lambda args, name="worker_runner_once": {
RUNNER_WATCH_WORKER = lambda bind_url: {
"name": "worker_runner_watch",
"command": _shell_join([str(resolve_env_binary("archivebox")), "manage", "runner_watch", f"--bind-url={bind_url}"]),
"command": _shell_join(archivebox_cmd("manage", "runner_watch", f"--bind-url={bind_url}")),
"autostart": "false",
"autorestart": "true",
"stdout_logfile": "logs/worker_runner_watch.log",
@ -213,12 +221,7 @@ RUNNER_WATCH_WORKER = lambda bind_url: {
SUPERVISORD_PARENT_WATCHDOG_WORKER = lambda supervisord_process_id: {
"name": "worker_supervisord_parent_watchdog",
"command": _shell_join(
[
str(resolve_env_binary("archivebox")),
"manage",
"supervisord_watchdog",
f"--supervisord-process-id={supervisord_process_id}",
],
archivebox_cmd("manage", "supervisord_watchdog", f"--supervisord-process-id={supervisord_process_id}"),
),
"autostart": "false",
"autorestart": "false",
@ -250,7 +253,7 @@ SERVER_WORKER = lambda host, port: {
def RUNSERVER_WORKER(host: str, port: str, *, reload: bool, nothreading: bool = False):
command = [str(resolve_env_binary("archivebox")), "manage", "runserver", f"{host}:{port}"]
command = archivebox_cmd("manage", "runserver", f"{host}:{port}")
if not reload:
command.append("--noreload")
if nothreading:
@ -1197,10 +1200,20 @@ def get_sonic_supervisord_worker_from_plugin(config) -> dict[str, str] | None:
return cast(dict[str, str] | None, worker)
_PROC_ACCESS_EXCEPTIONS = (
psutil.NoSuchProcess,
psutil.AccessDenied,
psutil.ZombieProcess,
PermissionError,
SystemError,
)
_PROC_INFO_EXCEPTIONS = (IndexError, *_PROC_ACCESS_EXCEPTIONS)
def _proc_cmdline(proc: psutil.Process) -> list[str]:
try:
return proc.cmdline()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
except _PROC_ACCESS_EXCEPTIONS:
return []
@ -1257,7 +1270,7 @@ def _sonic_listeners(host: str, port: int) -> list[psutil.Process]:
continue
try:
connections = proc.net_connections(kind="tcp")
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
except _PROC_ACCESS_EXCEPTIONS:
continue
for conn in connections:
if conn.status != psutil.CONN_LISTEN or not conn.laddr or conn.laddr.port != port:
@ -1288,7 +1301,7 @@ def stop_stale_sonic_processes(
if config_path is None or Path(cmdline[0]).name != "sonic" or str(config_path) not in cmdline:
continue
stale.append(proc)
except (IndexError, psutil.NoSuchProcess, psutil.AccessDenied):
except _PROC_INFO_EXCEPTIONS:
continue
if host is not None and port is not None:

View File

@ -82,8 +82,8 @@ dependencies = [
### Binary/Package Management
"abxbus==2.5.40", # EventBus API
"abxpkg==1.12.0", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins==1.12.0", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl==1.12.0", # shared ArchiveBox downloader package with blocking install preflight
"abx-plugins==1.12.1", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl==1.12.2", # 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
]

18
uv.lock
View File

@ -18,13 +18,13 @@ exclude-newer-span = "P5D"
[options.exclude-newer-package]
abxbus = { timestamp = "0001-01-01T00:00:00Z", span = "PT1S" }
abx-plugins = "2100-01-01T00:00:00Z"
abx-dl = "2100-01-01T00:00:00Z"
abx-plugins = "2100-01-01T00:00:00Z"
abxpkg = "2100-01-01T00:00:00Z"
[[package]]
name = "abx-dl"
version = "1.12.0"
version = "1.12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abx-plugins" },
@ -39,14 +39,14 @@ dependencies = [
{ name = "rich" },
{ name = "rich-click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/74/5d65ad86605b41567445b9da3a08de2240a198ed2704f94539d9b58b0fbd/abx_dl-1.12.0.tar.gz", hash = "sha256:f9f336ecbc6e40e20fe2ac83255bc67358728c55705a393604097d102c4030de", size = 87129, upload-time = "2026-07-25T22:43:02.99Z" }
sdist = { url = "https://files.pythonhosted.org/packages/22/84/4842add2a2c42153f450e5ffceccb5fc844427654b4ba5b2cdb4905111e2/abx_dl-1.12.2.tar.gz", hash = "sha256:6fd532a103162af3db4c1160720df4a27ba5a270cabefc1d8ab97d0338d2e31e", size = 87413, upload-time = "2026-07-26T01:29:47.799Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/ba/34c4997c3575cda1b14c5c5dc8bc06e24b4171dd0fcab3556294063ae586/abx_dl-1.12.0-py3-none-any.whl", hash = "sha256:7fe2277fe6388389ffcf20ad5ede5cab31e533d5c9be63b1e7231bb83f8a8785", size = 90873, upload-time = "2026-07-25T22:43:01.895Z" },
{ url = "https://files.pythonhosted.org/packages/df/b1/f802a0708448d10beec56b25410fcb5bb33a291056131974a7996f4854fa/abx_dl-1.12.2-py3-none-any.whl", hash = "sha256:f8d196b4b44c004e622e188e62374b201f835ff5189cd0a703be1d532b685463", size = 91176, upload-time = "2026-07-26T01:29:46.116Z" },
]
[[package]]
name = "abx-plugins"
version = "1.12.0"
version = "1.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "abxbus" },
@ -56,9 +56,9 @@ dependencies = [
{ name = "rich-click" },
{ name = "uv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/92/976cc591ad5169e7f60b9fadfeaa0e24bf39ffa7e9ed62aebef35780631e/abx_plugins-1.12.0.tar.gz", hash = "sha256:0b411cf351b4e47b5ce03e579509411e422251209459e055f7a58d020451a8a2", size = 257427, upload-time = "2026-07-25T22:15:32.555Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/96/f7bfab6f4d04a3eb058e3efa526d4453c82f3a0d965af850a69048bc4c44/abx_plugins-1.12.1.tar.gz", hash = "sha256:c7bca936c29196a9444c5e82b2646f6e0d40b0222e7f126a5a417842baa34d11", size = 257434, upload-time = "2026-07-26T01:12:03.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/5b/88b2170493025eb056aeb4d468dfd4d4e658b469ebe848938b31ba6efcf2/abx_plugins-1.12.0-py3-none-any.whl", hash = "sha256:0bf69dc4f1311bc0e3bdbb1e234f251837cc2b31cd267b5169d74f9b1ad186f5", size = 405631, upload-time = "2026-07-25T22:15:31.295Z" },
{ url = "https://files.pythonhosted.org/packages/85/2e/b5880e4be4b0dbb7e81654b80057e61b1cec72ae95b6a240e1b718f4d13b/abx_plugins-1.12.1-py3-none-any.whl", hash = "sha256:be41722f410872ef8b67bd729cb69728d68b2308c48bee7991d35001d3a8dadc", size = 405648, upload-time = "2026-07-26T01:12:02.199Z" },
]
[[package]]
@ -223,8 +223,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "abx-dl", specifier = "==1.12.0" },
{ name = "abx-plugins", specifier = "==1.12.0" },
{ name = "abx-dl", specifier = "==1.12.2" },
{ name = "abx-plugins", specifier = "==1.12.1" },
{ name = "abxbus", specifier = "==2.5.40" },
{ name = "abxpkg", specifier = "==1.12.0" },
{ name = "archivebox", extras = ["sonic", "ldap", "debug"], marker = "extra == 'all'" },