Sidecar contract (proxy → sidecar authentication)
July 21, 2026 · View on GitHub
Authoritative spec for loopback-sidecar extensions — a local HTTP server on
127.0.0.1:<port>. A token-v1 adapter reaches it through WebUI's same-origin
proxy; an explicitly legacy external adapter may still use direct loopback
access. The wire/authentication contract is language-neutral. A runtime committed
to this repository must use the canonical Python scaffold in
examples/sidecar-scaffold/; a runtime owned by
an external repository may implement the same contract in Node, Rust, or another
language.
Why this exists
The loopback port is reachable by any local process, and the WebUI proxy
strips every inbound credential (cookies, Authorization, CSRF, x-hermes-*)
before forwarding — so a sidecar cannot, on its own, tell a request that came
through the authenticated WebUI proxy from one a random local process sent
directly. proxy_auth: token-v1 closes that: WebUI mints a per-extension secret
and injects it as a header the sidecar validates.
Threat model — be honest about scope
The token converts "anyone who can send a loopback TCP packet" (other-UID
users, host containers, sandboxed network-only processes) into "processes that
can read the user's WebUI state dir" — the same protection level WebUI's own
auth already has. It does not defend against arbitrary same-UID code: a
process running as the same user can read the token file, read WebUI's signing
key, or just run your sidecar's underlying tool directly. No mechanism available
here (token, HMAC, nonce, Unix socket with 0600) changes that. Do not claim
otherwise in your README.
Manifest and runtime ownership
Every library entry declares whether its sidecar runtime is vendored in this
repository or external. Discovery comes from this metadata, never from the
presence of sidecar_base.py.
Repository-vendored runtime
"sidecar": {
"type": "loopback",
"origin": "http://127.0.0.1:17790",
"health_path": "/health",
"proxy_auth": "token-v1",
"runtime": {
"kind": "vendored",
"path": "sidecar"
}
}
Vendored runtimes must use token-v1. CI requires sidecar_base.py,
sidecar.py, sidecar.json, and routes_impl.py; byte-compares the two
protected scaffold files; and verifies that sidecar.json agrees with
extension.json on id, port, and auth mode. Because the canonical scaffold
serves plain HTTP on IPv4 loopback with a fixed liveness route, vendored metadata
must use http://127.0.0.1:<explicit-port> and health_path: "/health".
External runtime
"sidecar": {
"type": "loopback",
"origin": "http://127.0.0.1:17787",
"health_path": "/health",
"proxy_auth": "legacy",
"runtime": {
"kind": "external",
"repository": "https://github.com/example/sidecar-runtime"
}
}
External runtimes do not vendor the Python scaffold. token-v1 external
runtimes must implement the language-neutral conformance contract below. A
runtime that has not migrated must declare proxy_auth: "legacy" explicitly;
the validator reports that status rather than treating it as scaffold-compliant.
Desktop Companion is the current external/legacy compatibility case.
All runtime kinds use core-compatible endpoint syntax: origin is a bare
loopback HTTP(S) origin with no userinfo, path, query, or fragment, and
health_path is an absolute, segment-normalized path with no query or fragment.
The runtime manifest.json repeats type, origin, health_path, and
proxy_auth but omits the library-only runtime ownership object. CI requires
the repeated fields to match extension.json exactly.
Auth modes
token-v1— required for any sidecar that mutates state or exposes sensitive reads. WebUI injectsX-Hermes-Sidecar-Token; the sidecar validates.legacy— no token. Allowed only for an explicitly external runtime that has not migrated yet. New sidecars should usetoken-v1.- unknown value — fails closed (the sidecar declaration is rejected by core).
Core treats an omitted mode as legacy for backward compatibility. This library requires the field explicitly so legacy status cannot be mistaken for token-v1 conformance.
The token
- Provisioned by core, minted per-extension at
STATE_DIR/sidecar-auth/<id>.token(mode0600) when the operator grants sidecar-proxy consent. Proxy resolution reads the current persisted token and calls the same atomic provisioner if the file is missing. If the token cannot be persisted and re-read, consent or resolution fails closed with503. The sidecar never mints the token — it only reads it. - Header:
X-Hermes-Sidecar-Token, injected by the proxy on every forwarded request. Core strips any client-suppliedx-hermes-*inbound (so the browser can't forge it) and strips it from responses (so a sidecar can't echo it back). - Resolution order (the scaffold does this for you):
HERMES_EXT_SIDECAR_TOKEN_FILE→$HERMES_WEBUI_STATE_DIR/sidecar-auth/<id>.token→$HERMES_HOME/webui/sidecar-auth/<id>.token→ platform default (~/.hermes/webui/...,%LOCALAPPDATA%\hermes\webui\...on Windows). - Rotation: re-read the token per request (the scaffold does), so deleting/rotating the file takes effect with no restart, and a stale token stops validating immediately.
Enforcement rules (what the scaffold guarantees, and you must not weaken)
- Deny-by-default at one chokepoint. Every route except
/healthrequires a valid token. Auth is inherited from the dispatch loop, never invoked per-route — a forgotten call cannot open a route. /healthis the only tokenless route.GET/HEAD /healthreturns liveness only ({"ok": true}-class),Cache-Control: no-store. WebUI probes it cross-origin, so it also carriesAccess-Control-Allow-Origin: *. Do not put stats or any sensitive data on/health. Accepted trade-off:ACAO: *health means any website can fingerprint which sidecars you run via a drive-by loopback probe — acceptable for liveness only.- Missing token file → 503, wrong token → 401, both fail closed.
- No secrets in the
.servicefile. The token lives in the state dir; the unit names only the non-secret state and optional custom extension install directory.WorkingDirectorymust equal<HERMES_WEBUI_STATE_DIR>/extensions/<extension-id>/<runtime.path>by default. For an extension installed elsewhere, the unit declares the exact extension entry directory with the sidecar-onlyHERMES_EXT_INSTALL_DIRandWorkingDirectorymust equal its<runtime.path>child. Do not reuse WebUI'sHERMES_WEBUI_EXTENSION_DIR: WebUI supports both a single-entry development directory and a gallery root, so that variable is intentionally ambiguous here. Custom directories must be absolute or%h-anchored and CI emits an explicit reviewer warning. Reviewers must confirm each one is an operator-controlled installation directory, not a broadly writable or untrusted path.ExecStartmust run that directory's byte-comparedsidecar.pyas/usr/bin/python3 -S [-u] sidecar.py, with no command prefix or trailing tokens. The absolute interpreter path and mandatory-Sprevent PATH,sitecustomize, and.pthstartup hooks from replacing checked modules. IfTypeis present it must besimple, matching the foreground scaffold process. The[Service]section uses a small directive allowlist; auxiliaryExec*, environment files, root/image overrides, and other execution-context directives are forbidden. Systemd.service.d/*.confdrop-ins are also forbidden because they can replace the reviewed launch command. Direct shebang execution, arbitrary Python-looking executables,/usr/bin/envwrappers, and Quadlet.containerunits cannot prove the interpreter or image entrypoint and must be modeled as external. - One required route hook, one optional daemon hook.
routes_impl.register(app)is required, androutes_impl.pymust be a regular file that defines exactly one top-level synchronousregister(app)binding callable with that one argument. That binding is undecorated and must apply at least one@app.route(...)decorator (or the equivalent direct decorator application) in its linear body before any control-flow statement. A bareapp.route(...)call registers nothing and is rejected. Static checks reject direct, imported, assigned, pattern-capture, evaluated-definition (decorator, default, and annotation), and comprehension-walrus rebinding. Dynamic namespace mutation such asglobals()["register"] = ...is not generally provable and remains a review responsibility. Control-flow statements inroutes_impl.py's own module body are also rejected so that file cannot conditionally stop or replace route registration. Imported helper modules remain ordinary reviewed extension code.routes_impl.start_background(app)is called only when the module defines it. - The reviewed extension tree is the artifact that executes. Every entry
beneath an extension root, including declared assets and every component and
nested entry of
runtime.path, must be a real file or directory, never a symlink. Generated registry artifacts therefore package the same tree that validation inspected. Protected scaffold, entrypoint, config, and route files must be real regular files. Python import collisions next to them (sidecar_base/,routes_impl/, compiled or bytecode variants) are rejected so another module cannot shadow a byte-compared file.
Auth-off posture
WebUI authentication is optional and off by default, but token-v1 is
fail-closed in that posture. Both consent and proxy-target resolution return
403 until WebUI authentication is configured, regardless of whether the
sidecar origin is loopback. This prevents an unauthenticated caller from using
WebUI as a token-bearing forwarding oracle. The extensions status payload uses
posture: "local_unprotected" to prompt the operator; once authentication is
enabled it reports posture: "protected". Enabling auth is one field in
Settings → Password (or HERMES_WEBUI_PASSWORD).
The request/response envelope (do not fight it)
The proxy fully buffers both directions:
| Limit | Value |
|---|---|
| Response body | ≤ 512 KiB |
| Request body | ≤ the proxy cap (≈20 MiB) |
| Upstream timeout | ≈ 10 s |
| Streaming / SSE | not supported (buffered) |
For work longer than a few seconds, do not hold the request open — it will 502. Use start-job + poll:
POST /api/job -> {"job_id": "..."} # returns immediately
GET /api/job/{id} -> {"state": "running"|"done", "result": ...}
(This is exactly what a speed test / image pull / long scan needs, independent of auth — the 10 s proxy timeout makes it mandatory.)
Docker limitation
A bridge-networked WebUI container cannot reach a host-run sidecar's
127.0.0.1:<port> — loopback is namespace-local. Sidecars work only where core
and the sidecar share a network namespace and the state dir (sidecar inside the
WebUI container / shared hermes-home volume). Sidecars are not supported with
bridge-networked docker installs.
Versioning
sidecar_base.py carries SIDECAR_BASE_VERSION, echoed in /health and error
bodies so an installed-copy drift (users copy directories by hand; CI can't see
that) is diagnosable from the extensions panel. When the canonical scaffold
changes, bump the version and re-sync every vendored copy
(node scripts/sync-sidecar-base.mjs --write); CI's --check enforces identity.
Checklist for a new sidecar
- declare
sidecar.runtime.kindasvendoredorexternal - declare
proxy_authexplicitly in both metadata and runtime manifest - for vendored: use
token-v1and vendorsidecar_base.py+sidecar.pybyte-identical from the reference -
runtime.pathcontains no symlink and no canonical-module import collision - for external token-v1: implement the same header, health, status, rotation, and fail-closed behavior in the external runtime's language
- routes only in
routes_impl.py; noHTTPServer/framework server anywhere else -
sidecar.json={id, port, proxy_auth} -
.serviceuses/usr/bin/python3 -S [-u] sidecar.py; no token in the unit - long ops use start-job + poll; nothing relies on streaming
- README states the same-UID scope honestly and the docker limitation
-
node scripts/sync-sidecar-base.mjs --checkandnode scripts/check-sidecar-usage.mjspass