Files
hermes-agent/plugins/model-providers/actual/__init__.py
T
pierrenode 90253cc0a0 fix(providers): route Actual's fetch_models through the credential-redirect guard
ActualProfile.fetch_models() overrides ProviderProfile's default
implementation with its own Actual-specific base_url resolution
(ACTUAL_BASE_URL env var, hosted-vs-local normalization), but called raw
urllib.request.urlopen(req, timeout=timeout) directly instead of the base
class's open_credentialed_url(). Every other provider either uses the
base class default or forwards to it via super() and gets
SafeCredentialRedirectHandler for free — Actual is the only provider that
attaches a Bearer token to its own Request object and opens it with the
stdlib's default redirect handling, which forwards every header,
including Authorization, across a cross-origin redirect.

Actual's own feature surface makes the trigger realistic: ACTUAL_BASE_URL
is a first-class, documented way to point this provider at a self-hosted
or local-offline endpoint (see the local-loopback no-auth path already
handled elsewhere in this provider), so a misconfigured or compromised
endpoint 302-ing to another host leaks ACTUAL_API_KEY to it.

Fix: import and call the same open_credentialed_url() the base class
uses, keeping Actual's own URL-resolution logic unchanged.

Adds an end-to-end regression test using two real local HTTP servers (no
mocking of the security module itself) — one redirects, the other
records the Authorization header it receives — mirroring
test_urllib_security.py's own redirect tests. Also repoints the existing
fetch_models test's mock from urllib.request.urlopen to
hermes_cli.urllib_security.open_credentialed_url, since fetch_models no
longer calls the former. Mutation-verified: the new redirect test fails
on pre-fix code with the Authorization header observed at the redirect
target.
2026-08-14 16:43:37 -07:00

92 lines
2.8 KiB
Python

"""Actual Computer provider profile."""
from __future__ import annotations
import json
import logging
import os
from urllib.parse import urlparse
import urllib.request
from providers import register_provider
from providers.base import ProviderProfile, _profile_user_agent
logger = logging.getLogger(__name__)
DEFAULT_ACTUAL_BASE_URL = "https://api.actual.inc/v1"
DEFAULT_ACTUAL_LOCAL_BASE_URL = "http://127.0.0.1:8080/v1"
def _normalize_actual_base_url(base_url: str) -> str:
url = str(base_url or "").strip().rstrip("/")
if not url:
return DEFAULT_ACTUAL_BASE_URL
try:
parsed = urlparse(url)
host = (parsed.hostname or "").lower().rstrip(".")
path = parsed.path.rstrip("/")
except Exception:
return url
if host == "api.actual.inc" and path in {"", "/"}:
return url + "/v1"
if host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} and path in {"", "/"}:
return url + "/v1"
return url
class ActualProfile(ProviderProfile):
"""Actual Computer provider.
Hosted inference defaults to api.actual.inc. Local inference is exposed by
the Actual client only when it runs in offline mode, so users opt into it by
setting ACTUAL_BASE_URL to the local API URL.
"""
def fetch_models(
self,
*,
api_key: str | None = None,
base_url: str | None = None,
timeout: float = 8.0,
) -> list[str] | None:
base_url = _normalize_actual_base_url(
os.getenv("ACTUAL_BASE_URL", "").strip() or base_url or self.base_url
)
if not base_url:
return None
req = urllib.request.Request(base_url + "/models")
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", _profile_user_agent())
from hermes_cli.urllib_security import open_credentialed_url
try:
with open_credentialed_url(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode())
items = data if isinstance(data, list) else data.get("data", [])
return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
except Exception as exc:
logger.debug("fetch_models(actual): %s", exc)
return None
actual = ActualProfile(
name="actual",
aliases=("actual-computer", "actualcomputer", "aci"),
display_name="Actual Computer",
description=(
"Actual Computer - hosted inference via api.actual.inc, or local "
"offline inference via ACTUAL_BASE_URL"
),
signup_url="https://actual.inc",
env_vars=("ACTUAL_API_KEY", "ACTUAL_BASE_URL"),
base_url=DEFAULT_ACTUAL_BASE_URL,
auth_type="api_key",
api_mode="codex_responses",
)
register_provider(actual)