Fix Docker OpenClaw HMAC persistence and privacy-core build warnings.

Persist OPENCLAW_HMAC_SECRET to data/openclaw.env so empty Docker env vars no longer block auth after UI bootstrap. Add verify_hmac.py, silence Rust warnings (#423), and document Docker signing (#424).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-30 20:38:44 -06:00
parent 47faa1e488
commit c45b91c06c
7 changed files with 221 additions and 5 deletions
+6
View File
@@ -2988,6 +2988,12 @@ def _write_env_value(key: str, value: str) -> None:
# Also set in current process env so Settings picks it up
os.environ[key] = value
try:
from services.api_settings import persist_openclaw_env_value
persist_openclaw_env_value(key, value)
except Exception:
pass
# ---------------------------------------------------------------------------
# Agent Identity Management (Ed25519 keypair — used for mesh signing;
+36
View File
@@ -19,6 +19,10 @@ if not DATA_DIR.is_absolute():
OPERATOR_KEYS_ENV_PATH = Path(
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
)
OPENCLAW_ENV_PATH = Path(
os.environ.get("SHADOWBROKER_OPENCLAW_ENV", str(DATA_DIR / "openclaw.env"))
)
OPENCLAW_PERSISTED_KEYS = frozenset({"OPENCLAW_HMAC_SECRET", "OPENCLAW_ACCESS_TIER"})
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
# ---------------------------------------------------------------------------
@@ -285,6 +289,34 @@ def load_persisted_api_keys_into_environ() -> None:
os.environ[key] = value
def load_persisted_openclaw_into_environ() -> None:
"""Load OpenClaw secrets from the data volume when env is unset/empty.
Docker Compose often injects ``OPENCLAW_HMAC_SECRET=`` as an empty string,
which blocks pydantic from reading backend/.env. Persisting bootstrap output
under ``data/openclaw.env`` (on the backend_data volume) keeps remote HMAC
working across container restarts (#424).
"""
persisted = _parse_env_file(OPENCLAW_ENV_PATH)
if not persisted.get("OPENCLAW_HMAC_SECRET"):
# One-time migration from legacy backend/.env writes inside the image.
persisted = {**_parse_env_file(ENV_PATH), **persisted}
for key, value in persisted.items():
if key not in OPENCLAW_PERSISTED_KEYS:
continue
cleaned = str(value or "").strip()
if cleaned and not str(os.environ.get(key, "")).strip():
os.environ[key] = cleaned
def persist_openclaw_env_value(key: str, value: str) -> None:
"""Persist OpenClaw runtime settings to the data volume."""
if key not in OPENCLAW_PERSISTED_KEYS:
return
_write_env_values(OPENCLAW_ENV_PATH, {key: value})
def get_env_path_info() -> dict:
"""Return absolute paths for the backend .env and .env.example template.
@@ -305,6 +337,10 @@ def get_env_path_info() -> dict:
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
"openclaw_env_path": str(OPENCLAW_ENV_PATH.resolve()),
"openclaw_env_path_exists": OPENCLAW_ENV_PATH.exists(),
"openclaw_env_path_writable": os.access(OPENCLAW_ENV_PATH.parent, os.W_OK)
and (not OPENCLAW_ENV_PATH.exists() or os.access(OPENCLAW_ENV_PATH, os.W_OK)),
}
+5 -1
View File
@@ -370,8 +370,12 @@ class Settings(BaseSettings):
@lru_cache
def get_settings() -> Settings:
try:
from services.api_settings import load_persisted_api_keys_into_environ
from services.api_settings import (
load_persisted_api_keys_into_environ,
load_persisted_openclaw_into_environ,
)
load_persisted_api_keys_into_environ()
load_persisted_openclaw_into_environ()
except Exception:
pass
return Settings()
@@ -0,0 +1,76 @@
import hashlib
import hmac as hmac_mod
import json
import os
import secrets
import time
import pytest
from starlette.requests import Request
def test_persisted_openclaw_secret_loads_when_docker_env_blank(tmp_path, monkeypatch):
from services import api_settings
from services.config import get_settings
openclaw_env = tmp_path / "openclaw.env"
openclaw_env.write_text('OPENCLAW_HMAC_SECRET="persisted-hmac-secret"\n', encoding="utf-8")
monkeypatch.setattr(api_settings, "OPENCLAW_ENV_PATH", openclaw_env)
monkeypatch.setenv("OPENCLAW_HMAC_SECRET", "")
get_settings.cache_clear()
api_settings.load_persisted_openclaw_into_environ()
assert os.environ["OPENCLAW_HMAC_SECRET"] == "persisted-hmac-secret"
assert get_settings().OPENCLAW_HMAC_SECRET == "persisted-hmac-secret"
def test_persist_openclaw_env_value_writes_data_volume(tmp_path, monkeypatch):
from services import api_settings
openclaw_env = tmp_path / "openclaw.env"
monkeypatch.setattr(api_settings, "OPENCLAW_ENV_PATH", openclaw_env)
api_settings.persist_openclaw_env_value("OPENCLAW_HMAC_SECRET", "minted-secret")
assert 'OPENCLAW_HMAC_SECRET="minted-secret"' in openclaw_env.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_hmac_verify_accepts_canonical_json_from_docker_host(monkeypatch):
import auth
secret = "docker-hmac-test-secret"
monkeypatch.setattr(auth, "_openclaw_hmac_secret", lambda: secret)
body = json.dumps({"args": {}, "cmd": "channel_status"}, separators=(",", ":"), sort_keys=True).encode(
"utf-8"
)
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
digest = hashlib.sha256(body).hexdigest()
message = f"POST|/api/ai/channel/command|{ts}|{nonce}|{digest}"
signature = hmac_mod.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
async def receive():
return {"type": "http.request", "body": body}
req = Request(
{
"type": "http",
"method": "POST",
"path": "/api/ai/channel/command",
"headers": [
(b"x-sb-timestamp", ts.encode()),
(b"x-sb-nonce", nonce.encode()),
(b"x-sb-signature", signature.encode()),
],
"query_string": b"",
"root_path": "",
"server": ("172.17.0.1", 80),
"client": ("172.17.0.1", 12345),
},
receive,
)
assert await auth._verify_openclaw_hmac(req) is True