Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ servers and the dashboard v3 surfaces that consume them. Landed

### Added

- **ADR-0020 + OpenRouter callback skeleton + loopback guard**
(Phase 0 OpenRouter prereq). Documents why the future OAuth PKCE
callback URL is constrained to `127.0.0.1` so ADR-0012's LAN-trust
posture survives the V1 OpenRouter integration. Ships a registered
`GET /api/openrouter/auth/callback` route returning HTTP 501 with a
per-route loopback guard so V1 inherits a baseline that respects
the constraint from day 1. No live behaviour change.
- **Dashboard v3 `/agent` real-backend wiring** (#364, closes #207
#228 #227 #226). `useAgents()` hook against `/api/agents`; live
Memory tab against `/api/memory/graph/status`; live Skills tab
Expand Down
28 changes: 28 additions & 0 deletions docs/agents/hermes/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,33 @@ sandbox.
(e.g. the persona changes the tool allowlist and hermes needs a
fresh plugin load).

## OpenRouter OAuth (deferred to V1)

Wiring the bundled Hermes agent to use OpenRouter as a registered
upstream is gated behind the V1 (Phase 1) OpenRouter integration PR.
Phase 0 ships only the architectural scaffold:

- **ADR-0020** (`docs/internal/adr/0020-localhost-callback-only-oauth-pkce.md`)
documents why the OAuth PKCE callback URL is constrained to
`http://127.0.0.1:<port>/api/openrouter/auth/callback`. ADR-0012
removed every other auth surface; the callback is the one credential
surface we re-introduce, and we keep it off the LAN so the
trust-the-LAN posture still holds.
- **Operator note** — when V1 lands, completing the OAuth handshake
requires either a browser tab running on the hal0 host itself or an
SSH tunnel forwarding the laptop's `127.0.0.1:8080` to hal0's
`127.0.0.1:8080`. Plan for this in onboarding flows that assume a
remote browser (e.g. `hal0.thinmint.dev`).
- **Storage shape** — V1 will persist the OR refresh token + access
token to
`/var/lib/hal0/agents/{id}/personas/{pid}/openrouter.toml` (chmod
`0600`), matching the protections on the existing `runtime.json`.

The route skeleton at `/api/openrouter/auth/callback` is registered as
of Phase 0 and returns HTTP 501 with a pointer to ADR-0020 so V1's PR
can fill in the exchange flow against a baseline that already enforces
the loopback guard.

## See also

- [Hermes-Agent bootstrap](./hermes-bootstrap.md) — the 12-phase pipeline that touches surfaces #1-#7
Expand All @@ -304,3 +331,4 @@ sandbox.
- [`SERVICE.md`](./SERVICE.md) — `hal0-agent@.service` unit + restart endpoint
- ADR-0013 — agent-installer-managed MCP allowlist contract
- ADR-0019 — v0.3 integration roll-up
- ADR-0020 — localhost-callback-only OAuth PKCE (OpenRouter prereq)
3 changes: 2 additions & 1 deletion docs/internal/adr/0019-v0_3-hermes-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
- **Related:** ADR-0004 (agents v0.2 bundling), ADR-0005 (memory
engine — Cognee), ADR-0011 (agent identity card), ADR-0012 (auth +
Caddy removal), ADR-0013 (MCP client allow-list), ADR-0018 (upstream
Hermes pin + weekly drift detection)
Hermes pin + weekly drift detection), ADR-0020 (localhost-callback-only
OAuth PKCE — OpenRouter Phase 0 prereq)

## Context

Expand Down
122 changes: 122 additions & 0 deletions docs/internal/adr/0020-localhost-callback-only-oauth-pkce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# ADR 0020 — Localhost-callback-only OAuth PKCE (for OpenRouter BYOK)

- **Status:** Accepted
- **Date:** 2026-05-29
- **Drivers:** OpenRouter integration Phase 0 (V1 OpenRouter as Hermes
upstream); DA must-fix #4 (`openrouter-research-2026-05-28/notes/da-or.md`)
- **Related:** ADR-0012 (removed auth + Caddy); ADR-0019 (v0.3 Hermes
integration); future ADR-0021 (OpenRouter as registered upstream)

## Context

hal0-api binds `0.0.0.0:8080` with no Bearer authentication (ADR-0012).
The LAN-trust posture works because every privileged surface (slot
config, hermes restart, hermes-admin MCP) is gated either by being
loopback-only or by being deliberately operator-aware ("you can break it
from the LAN — that's your network").

OpenRouter's BYOK + delegate-routing UX requires OAuth 2.0 PKCE: the
hal0 dashboard launches an authorize URL, the user signs in at
`openrouter.ai`, OR redirects back to a callback URL on hal0 with an
authorization code, hal0 exchanges the code for a refresh token + access
token, persists them, and uses them on subsequent requests.

The callback URL is the new attack surface. If hal0 advertises a
callback at `http://<lan-ip>:8080/api/openrouter/auth/callback`, any LAN
host can:

- Race a real OAuth redirect to inject a code from an attacker's OR
session.
- DoS the callback with bogus codes to fill logs.
- (Mitigated by PKCE) replay a code without the verifier still fails.

Even with PKCE, the LAN-trust threat model strains. ADR-0012's posture
is "every privileged surface is operator-aware" — adding a
credential-storage surface that LAN hosts can poke isn't operator-aware.

## Decision

The OAuth PKCE callback URL is constrained to
`http://127.0.0.1:<port>/api/openrouter/auth/callback`.

Concretely:

- hal0-api keeps binding `0.0.0.0:8080` for the existing dashboard + tool
surfaces.
- The callback route `/api/openrouter/auth/callback` is registered, but
a per-route guard rejects every request whose `request.client.host` is
not loopback (`127.0.0.1`, `::1`, or the literal `localhost`).
- The authorize URL passed to `openrouter.ai` uses
`redirect_uri=http://127.0.0.1:8080/api/openrouter/auth/callback`.
- To complete the flow, the user must be ON the hal0 host (typing into a
browser tab there) or have an SSH tunnel forwarding
`127.0.0.1:8080` to their laptop's `127.0.0.1:8080`.

## Consequences

**Wins:**

- ADR-0012's LAN-trust model holds; no new attack surface visible from
the LAN.
- Refresh-token storage at
`/var/lib/hal0/agents/{id}/personas/{pid}/openrouter.toml` (chmod
`0600`) is the only credential at rest — same protection as
`runtime.json` from v0.3.
- PKCE + localhost-only = belt-and-suspenders against code-injection
races.
- No reverse-proxy / Caddy dependency reintroduced (ADR-0012 §"What was
deleted").

**Trade-offs:**

- Users connecting to hal0 from a remote browser must SSH-tunnel
`:8080` to complete the OAuth handshake (one-time per persona /
account).
- Some friction on first-time setup; documented in the operator manual
+ onboarding tour.
- A future dashboard hosted at `hal0.thinmint.dev` (Traefik vhost)
cannot complete the flow without either (a) dual-binding the callback
to the public URL with a separate auth model OR (b) running the flow
from the LXC host's local browser.

**Deferred to v0.4 (if there's real demand):**

- Dual-bind callback: public URL + Bearer token + nonce + Origin
allowlist.
- This explicitly re-opens ADR-0012 and requires a new ADR.

## Alternatives considered

1. **Drop OAuth, demand the user paste their OpenRouter API key
directly.**
Simpler. No callback. Key lives at
`/var/lib/hal0/agents/{id}/personas/{pid}/openrouter.env` (chmod
`0600`). Loses OR's PKCE-delegate flow (where end-users authorize OR
access without exposing their downstream provider keys to hal0).
User experience: paste a key VS click a button. The button is the
OR-UX-aligned choice. **Rejected** for v0.3+; revisit if operator
feedback shows the localhost-tunnel friction is meaningful.

2. **Public callback + Bearer + Origin allowlist.**
Reverts ADR-0012's posture entirely. **Rejected** for v0.3+;
possible v0.5+ if hal0 grows hosted-dashboard demand.

3. **Bind callback to LAN, accept the risk.**
**Rejected** — even with PKCE, the credential-storage surface is
operator-aware in a way LAN hosts aren't trusted to be.

## Implementation pointer

V1 (the OpenRouter-as-Hermes-upstream PR) will wire the actual callback
flow into `src/hal0/api/openrouter/` (NEW module). A minimal route
skeleton lands in this PR (`status=501`) so the URL is registered and
the loopback guard is enforced from day 1.

## Quotes

ADR-0012 §"Context" — "Every operator who has run it for real puts it
behind an existing upstream proxy [...] The bundled Caddy was middleman
[...] doubled the failure surface."

`openrouter-research-2026-05-28/PLANNING.md` §5 Q1 — "localhost-only
first; new ADR-0020 documents the constraint."
11 changes: 11 additions & 0 deletions src/hal0/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)
from hal0.api.agents.chat_proxy import router as chat_proxy_router
from hal0.api.middleware import error_codes, log_scrub, request_id
from hal0.api.openrouter import router as openrouter_auth_router
from hal0.api.plugins import router as plugin_manifest_router
from hal0.api.routes import (
agents as agents_routes,
Expand Down Expand Up @@ -1025,6 +1026,16 @@ def create_app() -> FastAPI:
tags=["mcp"],
)

# OpenRouter OAuth callback scaffold (ADR-0020, Phase 0). The route
# is registered so V1 (the OpenRouter-as-Hermes-upstream PR) inherits
# the loopback guard from day 1; the handler currently returns 501
# with a pointer to ADR-0020. Router declares absolute paths so no
# prefix is needed here.
app.include_router(
openrouter_auth_router,
tags=["openrouter", "auth"],
)

# Hermes dashboard plugin host (v0.3 PR-7). hal0-api proxies the
# upstream manifest list + the per-plugin static-asset surface so
# the v3 dashboard can mount upstream's plugin bundles (kanban
Expand Down
15 changes: 15 additions & 0 deletions src/hal0/api/openrouter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""OpenRouter integration surface (Phase 0 scaffold, ADR-0020).

This package owns hal0-api's side of the OpenRouter BYOK + delegate
flow. v0.3.x ships only the route scaffold + loopback guard so V1
(the OpenRouter-as-Hermes-upstream PR) inherits a baseline that
respects ADR-0012's auth-removed posture from day 1.

See ``docs/internal/adr/0020-localhost-callback-only-oauth-pkce.md``
for the architectural decision; the actual PKCE exchange flow lands in
V1 (Phase 1) on top of this scaffold.
"""

from hal0.api.openrouter.auth import router

__all__ = ["router"]
86 changes: 86 additions & 0 deletions src/hal0/api/openrouter/_loopback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Loopback-only guard helpers for the OpenRouter OAuth callback.

Quoting ADR-0020:

> The OAuth PKCE callback URL is constrained to
> ``http://127.0.0.1:<port>/api/openrouter/auth/callback``.
>
> hal0-api keeps binding ``0.0.0.0:8080`` for the existing dashboard +
> tool surfaces. The callback route ``/api/openrouter/auth/callback``
> is registered, but a per-route guard rejects every request whose
> ``request.client.host`` is not loopback.

This module owns the host-classification primitive (``is_loopback_host``)
plus a FastAPI-friendly helper (``require_loopback``) that raises a
typed HTTPException on a non-loopback client. Both are deliberately
small, dependency-free, and unit-tested in isolation so V1 (the actual
PKCE exchange flow) inherits a well-behaved foundation.

The loopback guard is implemented per-route, not as a global
middleware, because every other hal0-api surface intentionally accepts
LAN traffic. A global middleware would force allowlisting the rest of
the API, inverting the decision recorded in ADR-0012.
"""

from __future__ import annotations

from fastapi import HTTPException, Request, status

# IPv4 + IPv6 loopback literals, plus the textual ``localhost`` form
# that some browsers / OS resolvers still hand to ASGI servers when the
# DNS entry resolves to 127.0.0.1. Anything else is treated as
# untrusted — including private RFC1918 LAN ranges such as 10.0.1.0/24.
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost"})


def is_loopback_host(host: str | None) -> bool:
"""Return ``True`` only for loopback client hosts.

Accepts the IPv4 loopback (``127.0.0.1``), the IPv6 loopback
(``::1``), and the literal hostname ``localhost``. Everything else
— including private LAN ranges, public IPs, empty strings, and
``None`` — returns ``False``.

The check is intentionally a strict allowlist rather than a CIDR
test against ``127.0.0.0/8``: hal0-api is bound to a single
interface, and the only loopback address ASGI servers hand to
request scopes in practice is ``127.0.0.1`` (or ``::1`` for an
IPv6 listener). A broader range would silently accept spoofed
headers in adversarial deployments.
"""
if not host:
return False
return host in _LOOPBACK_HOSTS


def require_loopback(request: Request) -> None:
"""Raise ``HTTPException(403)`` for non-loopback callers.

Designed to be called at the top of a route handler — see
``hal0.api.openrouter.auth.callback``. Returns ``None`` on success
so the route body proceeds normally; raises on failure so FastAPI
serialises the typed error envelope.

The 403 (not 404) is deliberate: leaking the existence of the
callback URL is harmless (the URL is in the public ADR), and 403
makes the "you must complete this flow over localhost / an SSH
tunnel" story explicit to operators who hit the endpoint from
their laptop.
"""
client = request.client
host = client.host if client is not None else None
if is_loopback_host(host):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "loopback_required",
"message": (
"OpenRouter OAuth callback is constrained to loopback per "
"ADR-0020. Complete the flow from the hal0 host or SSH-tunnel "
"127.0.0.1:8080 to your local machine."
),
"adr": "ADR-0020",
"client_host": host,
},
)
49 changes: 49 additions & 0 deletions src/hal0/api/openrouter/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""OpenRouter OAuth PKCE callback route (Phase 0 scaffold).

This module registers ``GET /api/openrouter/auth/callback`` so the URL
exists, is reachable, and enforces the loopback guard from ADR-0020
before V1 lands the actual PKCE exchange flow. The handler returns
HTTP 501 with a pointer to ADR-0020 — V1's PR opens against this
branch and fills the body in.

See ``docs/internal/adr/0020-localhost-callback-only-oauth-pkce.md``.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse

from hal0.api.openrouter._loopback import require_loopback

router = APIRouter()


@router.get(
"/api/openrouter/auth/callback",
status_code=status.HTTP_501_NOT_IMPLEMENTED,
tags=["openrouter", "auth"],
summary="OpenRouter OAuth PKCE callback (scaffold; V1 lands the exchange)",
)
async def callback(
request: Request,
_loopback: None = Depends(require_loopback),
) -> JSONResponse:
"""Receive the OAuth authorization code from OpenRouter.

Phase 0 (this PR) registers the route + loopback guard only. The
PKCE code-for-token exchange + refresh-token persistence ship in
V1 (Phase 1) — see ADR-0020 §"Implementation pointer".

The 501 response is deliberate: it documents the contract for V1
and lets the dashboard's "Linked Accounts" panel detect that the
callback URL is reachable before exposing a button that would
otherwise hang.
"""
return JSONResponse(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
content={
"detail": ("callback wired by V1; PR-#### lands the exchange flow"),
"adr": "ADR-0020",
},
)
Loading
Loading