Files
homeassistant-stelloauth-addon/docs/superpowers/plans/2026-09-24-home-assistant-stelloauth-addon.md
T

37 KiB

Home Assistant Stelloauth Add-on Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.

Goal: Build a source-only Home Assistant custom add-on repository that runs pinned Stelloauth and CloakBrowser in one Supervisor-managed container and gives Stellantis Vehicles the exact internal worker URL.

Architecture: A small Python process manager starts a loopback-only CloakBrowser CDP service, proves it is ready, then starts a locally built and security-patched Stelloauth listener. Home Assistant Core reaches only Stelloauth through Supervisor DNS; the repository never redistributes CloakBrowser's proprietary binary and never publishes a derived image.

Tech Stack: Home Assistant add-on metadata, Docker BuildKit, Go 1.27.1, Stelloauth v0.6.0, CloakBrowser 0.5.10, Python 3.12 standard library, pytest, PyYAML, Gitea Actions.

Spec: docs/superpowers/specs/2026-09-24-home-assistant-stelloauth-addon-design.md

Global Constraints

  • Pin Stelloauth to tag v0.6.0 and commit 367d4f8c02a3b072c59142c49dffc129edc8548b.
  • Pin CloakBrowser to 0.5.10, commit f04c23da285b3b3d3cf10c8f9d282e7adc1d52ce, and OCI index digest sha256:2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76.
  • Pin the builder to golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195.
  • Support amd64 and aarch64; the inspected HAOS target is amd64.
  • Use one non-privileged add-on with init: true; request no host network, Docker API, devices, maps, ingress, or full access.
  • Keep host port 8080/tcp disabled by default and bind CDP only to 127.0.0.1:9222.
  • Document the exact internal worker URL as http://0031621f-stelloauth:8080/worker.
  • Keep CLOAK_MAX_SESSIONS=1; expose only queue_timeout, rate_limit_count, and rate_limit_duration.
  • Never persist or log passwords, email addresses, OAuth codes, cookies, access tokens, refresh tokens, redirect URLs, or browser URLs.
  • Accept worker URLs only for HTTPS on the default port and exact /am/oauth2/authorize path at the five approved identity hosts.
  • Limit /worker bodies to 64 KiB and rate-limit OAuth by direct peer address.
  • Do not push, tag, release, publish an image, install on HAOS, or change production Home Assistant settings.

Review Focus

  • Invalid JSON, missing or unknown option keys, booleans used as integers, zero counts, and malformed durations must stop startup with one fixed non-secret error; Task 3 tests them.
  • URL userinfo, explicit ports, deceptive suffixes, escaped paths, fragments, schemes, and case normalization must obey the exact allowlist; Task 2 tests them.
  • A live CloakBrowser root page is insufficient; readiness must require a loopback webSocketDebuggerUrl and close its named readiness profile before Stelloauth starts; Task 3 tests it.
  • Every unexpected child exit, including exit 0, must stop and reap the sibling and make the manager fail; Task 3 tests both directions.
  • SIGTERM or SIGINT during readiness or shutdown must stop both process groups within ten seconds, escalate to SIGKILL, and reap all children; Task 3 tests it.

File Map

Path Responsibility
.gitignore Ignore Python caches, pytest state, and local evidence.
requirements-dev.txt and pytest.ini Pin and configure the Python test harness.
repository.yaml Describe the repository to Home Assistant.
stelloauth/config.yaml Define Supervisor metadata, options, disabled host port, and watchdog.
stelloauth/Dockerfile Verify pins, apply patches, test/build Stelloauth, and assemble the local runtime.
stelloauth/rootfs/usr/local/bin/addon-supervisor Validate options and supervise both process groups.
stelloauth/patches/stelloauth-security.patch Harden URL validation, body size, rate-limit identity, and logs.
stelloauth/patches/cloakserve-loopback.patch Bind CloakBrowser to container loopback.
stelloauth/translations/{da,en}.yaml Describe the three options in Supervisor.
README.md, stelloauth/README.md, stelloauth/DOCS.md Repository and user installation documentation.
LICENSE and stelloauth/CHANGELOG.md Repository license and version history.
tests/test_addon_metadata.py Validate metadata, pins, DNS derivation, and documentation.
tests/test_security_patches.py Apply/test patches against exact upstream commits.
tests/test_supervisor.py Unit-test parsing, readiness, lifecycle, signals, and logs.
tests/test_runtime.sh Build and exercise the real amd64 container.
.gitea/workflows/ci.yml Repeat supported checks without publishing or deploying.

Task 1: Supervisor metadata and repository contract

Files:

  • Create: .gitignore
  • Create: requirements-dev.txt
  • Create: pytest.ini
  • Create: repository.yaml
  • Create: stelloauth/config.yaml
  • Create: stelloauth/translations/da.yaml
  • Create: stelloauth/translations/en.yaml
  • Create: tests/test_addon_metadata.py

Interfaces:

  • Consumes: the approved repository URL, version, architecture, and options.

  • Produces: the options contract consumed by load_options() in Task 3 and the port/watchdog contract consumed by Supervisor.

  • Step 1: Add the test harness and failing metadata tests

Create requirements-dev.txt:

pytest==8.4.2
PyYAML==6.0.2

Create pytest.ini:

[pytest]
markers =
    upstream: fetches and validates pinned upstream source
    docker: builds or runs the add-on image

Create tests/test_addon_metadata.py with:

from __future__ import annotations
import hashlib
from pathlib import Path
import yaml

ROOT = Path(__file__).parents[1]
REPOSITORY_URL = "https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon.git"

def load_yaml(path: str) -> dict:
    with (ROOT / path).open(encoding="utf-8") as handle:
        value = yaml.safe_load(handle)
    assert isinstance(value, dict)
    return value

def test_repository_metadata() -> None:
    metadata = load_yaml("repository.yaml")
    assert metadata["url"] == REPOSITORY_URL
    assert metadata["name"] == "Stelloauth for Home Assistant"
    assert metadata["maintainer"] == "Dennis / Radix ApS"

def test_addon_contract() -> None:
    config = load_yaml("stelloauth/config.yaml")
    assert config["slug"] == "stelloauth"
    assert config["version"] == "0.1.0"
    assert config["arch"] == ["amd64", "aarch64"]
    assert config["startup"] == "application"
    assert config["boot"] == "auto"
    assert config["init"] is True
    assert config["watchdog"] == "http://[HOST]:[PORT:8080]/"
    assert config["ports"] == {"8080/tcp": None}
    for key in ("ingress", "host_network", "privileged", "full_access", "docker_api", "devices", "map"):
        assert key not in config

def test_defaults_and_schema_are_aligned() -> None:
    config = load_yaml("stelloauth/config.yaml")
    assert config["options"] == {
        "queue_timeout": "60s",
        "rate_limit_count": 5,
        "rate_limit_duration": "1h",
    }
    assert set(config["schema"]) == set(config["options"])
    assert config["schema"]["rate_limit_count"] == "int(1,20)"

def test_repository_hostname_derivation() -> None:
    repository_id = hashlib.sha1(REPOSITORY_URL.lower().encode()).hexdigest()[:8]
    assert repository_id == "0031621f"
    assert f"{repository_id}-stelloauth" == "0031621f-stelloauth"

def test_translations_cover_every_option() -> None:
    keys = set(load_yaml("stelloauth/config.yaml")["options"])
    for language in ("da", "en"):
        translation = load_yaml(f"stelloauth/translations/{language}.yaml")
        assert set(translation["configuration"]) == keys
        for entry in translation["configuration"].values():
            assert set(entry) == {"name", "description"}
            assert all(isinstance(value, str) and value.strip() for value in entry.values())
  • Step 2: Prove the tests are red

Run:

python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pytest tests/test_addon_metadata.py -q

Expected: FAIL with FileNotFoundError for repository.yaml.

  • Step 3: Add the minimal metadata

Create repository.yaml:

name: Stelloauth for Home Assistant
url: https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon.git
maintainer: Dennis / Radix ApS

Create stelloauth/config.yaml:

name: Stelloauth
version: "0.1.0"
slug: stelloauth
description: Local OAuth worker for Stellantis Vehicles using CloakBrowser
url: https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon
arch:
  - amd64
  - aarch64
startup: application
boot: auto
init: true
watchdog: http://[HOST]:[PORT:8080]/
ports:
  8080/tcp: null
ports_description:
  8080/tcp: Temporary LAN access for troubleshooting only
options:
  queue_timeout: 60s
  rate_limit_count: 5
  rate_limit_duration: 1h
schema:
  queue_timeout: match(^[1-9][0-9]*(ms|s|m|h)$)
  rate_limit_count: int(1,20)
  rate_limit_duration: match(^[1-9][0-9]*(ms|s|m|h)$)

Create both translation files with configuration entries for all three keys. Danish labels are Køventetid, Loginforsøg, and Rate limit-periode; English labels are Queue timeout, Login attempts, and Rate limit period. Each gets one sentence describing its behavior.

Create .gitignore:

.venv/
.pytest_cache/
__pycache__/
*.pyc
.coverage
artifacts/
  • Step 4: Prove metadata is green and review the exact diff

Run:

.venv/bin/pytest tests/test_addon_metadata.py -q
git diff --check
git diff -- repository.yaml stelloauth/config.yaml stelloauth/translations tests/test_addon_metadata.py

Expected: all tests PASS; no privileged capability exists and 8080/tcp is null.

  • Step 5: Commit

    git add .gitignore requirements-dev.txt pytest.ini repository.yaml stelloauth/config.yaml stelloauth/translations tests/test_addon_metadata.py git commit -m "feat: add Home Assistant addon metadata"

Task 2: Reproducible upstream security patches

Files:

  • Create: stelloauth/patches/stelloauth-security.patch
  • Create: stelloauth/patches/cloakserve-loopback.patch
  • Create: tests/test_security_patches.py

Interfaces:

  • Consumes: exact upstream commits from Global Constraints.

  • Produces: patches that Task 4 applies during the image build.

  • Step 1: Write failing repository-level patch tests

Create tests/test_security_patches.py:

from __future__ import annotations
import subprocess
from pathlib import Path
import pytest

ROOT = Path(__file__).parents[1]
STELLOAUTH_COMMIT = "367d4f8c02a3b072c59142c49dffc129edc8548b"
CLOAK_COMMIT = "f04c23da285b3b3d3cf10c8f9d282e7adc1d52ce"

def run(*args: str, cwd: Path):
    return subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=True)

def fetch_exact(tmp_path: Path, name: str, url: str, commit: str) -> Path:
    target = tmp_path / name
    run("git", "init", str(target), cwd=tmp_path)
    run("git", "remote", "add", "origin", url, cwd=target)
    run("git", "fetch", "--depth=1", "origin", commit, cwd=target)
    run("git", "checkout", "--detach", "FETCH_HEAD", cwd=target)
    assert run("git", "rev-parse", "HEAD", cwd=target).stdout.strip() == commit
    return target

@pytest.mark.upstream
def test_stelloauth_patch_applies_and_tests_pass(tmp_path: Path) -> None:
    source = fetch_exact(tmp_path, "stelloauth", "https://github.com/tamcore/stelloauth.git", STELLOAUTH_COMMIT)
    patch = ROOT / "stelloauth/patches/stelloauth-security.patch"
    run("git", "apply", "--check", str(patch), cwd=source)
    run("git", "apply", str(patch), cwd=source)
    run("go", "test", "./...", cwd=source)

@pytest.mark.upstream
def test_cloak_patch_binds_only_loopback(tmp_path: Path) -> None:
    source = fetch_exact(tmp_path, "cloakbrowser", "https://github.com/CloakHQ/CloakBrowser.git", CLOAK_COMMIT)
    patch = ROOT / "stelloauth/patches/cloakserve-loopback.patch"
    run("git", "apply", "--check", str(patch), cwd=source)
    run("git", "apply", str(patch), cwd=source)
    wrapper = (source / "bin/cloakserve").read_text(encoding="utf-8")
    assert 'host = "127.0.0.1"' in wrapper
    assert 'host = "0.0.0.0" if in_container' not in wrapper
    run("python3", "-m", "py_compile", "bin/cloakserve", cwd=source)

Run:

.venv/bin/pytest tests/test_security_patches.py -q

Expected: FAIL because both patch files are absent.

  • Step 2: Add red tests to an exact Stelloauth checkout

Create /tmp/stelloauth-addon-patch at commit 367d4f8c02a3b072c59142c49dffc129edc8548b. In internal/app/worker_test.go add table-driven validation with these allowed inputs:

https://idpcvs.citroen.com/am/oauth2/authorize?redirect_uri=mycitroen%3A%2F%2Foauth2redirect%2Fdk
https://idpcvs.driveds.com/am/oauth2/authorize?redirect_uri=mymap%3A%2F%2Foauth2redirect%2Fdk
https://idpcvs.opel.com/am/oauth2/authorize?redirect_uri=myopel%3A%2F%2Foauth2redirect%2Fdk
https://idpcvs.peugeot.com/am/oauth2/authorize?redirect_uri=mypeugeot%3A%2F%2Foauth2redirect%2Fdk
https://idpcvs.vauxhall.co.uk/am/oauth2/authorize?redirect_uri=myvauxhall%3A%2F%2Foauth2redirect%2Fgb
https://IDPCVS.OPEL.COM/am/oauth2/authorize?redirect_uri=myopel%3A%2F%2Foauth2redirect%2Fdk

Add rejected inputs for HTTP, userinfo, explicit :443, an evil suffix, an extra path component, an escaped path, and a fragment. Add these tests:

  • TestHandleWorker_RequestTooLarge: send 65 KiB, expect 413 and Request body too large.
  • TestRemoteClientIPIgnoresForwardedHeaders: RemoteAddr 192.0.2.10:1234 with spoofed forwarded headers returns 192.0.2.10.
  • TestRequestLogsDoNotContainCredentialsOrURLs: capture logs and reject sentinel email, password, authorize URL, redirect URL, and OAuth code.
  • Update the existing TestRedirectScheme success cases to approved hosts.

Run:

cd /tmp/stelloauth-addon-patch
go test ./internal/app -run 'TestValidateWorkerURL|TestHandleWorker_RequestTooLarge|TestRemoteClientIPIgnoresForwardedHeaders|TestRequestLogsDoNotContainCredentialsOrURLs|TestRedirectScheme' -count=1

Expected: FAIL because validation, body limiting, direct peer identity, and redaction are absent.

  • Step 3: Implement the minimal upstream hardening

In internal/app/worker.go define maxWorkerRequestBytes = 64 << 10 and validateWorkerURL(rawURL string) error. Parse with net/url and require:

  • parsed.Scheme == "https";
  • parsed.User == nil;
  • parsed.Port() == "";
  • parsed.Fragment == "";
  • strings.ToLower(parsed.Hostname()) is exactly one approved host;
  • parsed.EscapedPath() == "/am/oauth2/authorize".

Call validation before redirectScheme. Wrap the body with http.MaxBytesReader; identify *http.MaxBytesError using errors.As and return 413 with the fixed message.

Add remoteClientIP(r) based only on r.RemoteAddr using net.SplitHostPort with a bare-IP fallback. Use it for /oauth and /worker rate-limit keys and logs. Keep the current proxy-aware helper only for /geo.

Replace sensitive messages with:

log.Printf("[%s] worker OAuth request from %s", requestID, clientIP)
log.Printf("[%s] OAuth request from %s (%s/%s)", requestID, clientIP, req.Brand, req.Country)
log.Printf("[%s] Captured OAuth redirect", requestID)
log.Printf("[%s] Stellantis error page received", requestID)
log.Printf("Current browser location checked")

Ensure returned/logged error strings do not interpolate current or redirect URLs.

  • Step 4: Prove upstream tests pass and export the patch

Run:

cd /tmp/stelloauth-addon-patch
gofmt -w internal/app/worker.go internal/app/worker_test.go internal/app/server.go internal/app/server_test.go internal/app/oauth.go internal/app/oauth_test.go
go test ./... -count=1
git diff --check
mkdir -p "/Users/dennis/Gitea/HA Addon Stellantis/stelloauth/patches"
git diff --binary > "/Users/dennis/Gitea/HA Addon Stellantis/stelloauth/patches/stelloauth-security.patch"

Expected: all upstream tests PASS; the patch includes both tests and code.

  • Step 5: Make the CloakBrowser listener patch

Checkout CloakBrowser commit f04c23da285b3b3d3cf10c8f9d282e7adc1d52ce in /tmp/cloakbrowser-addon-patch. Replace only:

in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
host = "0.0.0.0" if in_container else "127.0.0.1"

with:

host = "127.0.0.1"

Run Python bytecode compilation, diff check, and export the binary diff to stelloauth/patches/cloakserve-loopback.patch.

  • Step 6: Validate fresh patch application and commit

Run:

.venv/bin/pytest tests/test_security_patches.py -q
git diff --check
git diff -- stelloauth/patches tests/test_security_patches.py

Expected: both exact-commit patch tests PASS.

git add stelloauth/patches tests/test_security_patches.py
git commit -m "fix: harden stelloauth oauth worker"

Task 3: Options, readiness, and process lifecycle

Files:

  • Create: stelloauth/rootfs/usr/local/bin/addon-supervisor
  • Create: tests/test_supervisor.py

Interfaces:

  • Consumes: /data/options.json with queue_timeout: str, rate_limit_count: int, rate_limit_duration: str.

  • Produces: Options, load_options(path: Path) -> Options, build_environment(options: Options) -> dict[str, str], HttpResponse, probe_cloak(request: Callable[[str, str, float], HttpResponse]) -> None, wait_until_ready(name: str, probe: Callable[[], None], timeout: float, stopping: Callable[[], bool], monotonic, sleep) -> None, and ProcessManager.run() -> int.

  • Step 1: Write red option and environment tests

Load the extensionless script with importlib.machinery.SourceFileLoader. Assert valid defaults map to:

CLOAK_CDP_URL=http://127.0.0.1:9222
CLOAK_MAX_SESSIONS=1
CLOAK_QUEUE_TIMEOUT=60s
RATE_LIMIT_COUNT=5
RATE_LIMIT_DURATION=1h
HTTP_ADDRESS=0.0.0.0
PORT=8080
METRICS_ADDRESS=127.0.0.1
METRICS_PORT=9090

Parameterize invalid cases: empty object, unknown key, missing key, 0s, duration without unit, boolean count, count 0, count 21, malformed JSON, and unreadable file. Every case raises ConfigError("Invalid add-on configuration"), and captured logs contain no input value.

Run:

.venv/bin/pytest tests/test_supervisor.py -k 'options or environment' -q

Expected: FAIL because the script is absent.

  • Step 2: Implement strict parsing and environment construction

The script begins:

#!/usr/bin/env python3
from __future__ import annotations

import dataclasses
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Callable, NamedTuple

OPTIONS_PATH = Path("/data/options.json")
PROFILE_PATH = Path("/tmp/cloakserve")
CLOAK_ROOT = "http://127.0.0.1:9222/"
CLOAK_VERSION = "http://127.0.0.1:9222/json/version?fingerprint=addon-readiness"
CLOAK_CLOSE = "http://127.0.0.1:9222/fingerprint/addon-readiness/close"
STELLOAUTH_ROOT = "http://127.0.0.1:8080/"
DURATION = re.compile(r"^[1-9][0-9]*(?:ms|s|m|h)$")

class ConfigError(RuntimeError):
    pass

@dataclasses.dataclass(frozen=True)
class Options:
    queue_timeout: str
    rate_limit_count: int
    rate_limit_duration: str

class HttpResponse(NamedTuple):
    status: int
    body: bytes

load_options must require exactly the three keys, reject bool before the integer range check, validate both durations, and translate every parse/read/type failure to the fixed ConfigError. build_environment returns a copy of os.environ plus the nine exact variables above. Configure message-only INFO logs; the main path logs only the fixed error and exits 2 on invalid configuration.

Run the focused tests; expected PASS.

  • Step 3: Write red readiness tests

Test probe_cloak with an injected request callable. Require calls in this order:

GET  http://127.0.0.1:9222/
GET  http://127.0.0.1:9222/json/version?fingerprint=addon-readiness
POST http://127.0.0.1:9222/fingerprint/addon-readiness/close

The version body must contain a non-empty webSocketDebuggerUrl beginning ws://127.0.0.1:. Reject empty JSON, empty URL, non-JSON, non-200, and a non-loopback websocket. Test probe_stelloauth requires HTTP 200 at its root.

Test wait_until_ready with injected monotonic and sleep functions: transient exceptions back off from 0.25 seconds to at most 2 seconds; Cloak expires at 60 seconds; Stelloauth expires at 30 seconds; a stop flag aborts immediately.

Run:

.venv/bin/pytest tests/test_supervisor.py -k 'probe or readiness or backoff' -q

Expected: FAIL because readiness functions are absent.

  • Step 4: Implement bounded, proxy-free readiness

Implement http_request(method, url, timeout) using urllib.request.build_opener(urllib.request.ProxyHandler({})). Never log bodies, returned URLs, option values, or exception strings.

Use this retry contract:

class ReadinessError(RuntimeError):
    pass

def wait_until_ready(name, probe, timeout, stopping,
                     monotonic=time.monotonic, sleep=time.sleep):
    deadline = monotonic() + timeout
    delay = 0.25
    while monotonic() < deadline and not stopping():
        try:
            probe()
            return
        except (OSError, ValueError, ReadinessError):
            sleep(delay)
            delay = min(delay * 2, 2.0)
    raise ReadinessError(f"{name} did not become ready")

probe_cloak parses and validates the websocket field, then closes the readiness profile. probe_stelloauth checks root status only.

  • Step 5: Write red lifecycle and shutdown tests

Build FakeProcess objects with pid, poll(), wait(timeout), and recorded group signals. Inject process creation, killpg, probes, sleep, and monotonic into ProcessManager. Assert exact startup events:

clean profiles
start /usr/local/bin/cloakserve --headless=true --idle-timeout=30 --data-dir=/tmp/cloakserve
probe cloak
start /usr/local/bin/stelloauth
probe stelloauth

Cover:

  • stale profile removal and recreation with mode 0700;
  • Cloak readiness failure never starts Stelloauth;
  • Stelloauth readiness failure stops both;
  • either child returning 0 or nonzero unexpectedly stops its sibling and returns nonzero;
  • SIGTERM and SIGINT stop both groups and return 0;
  • an ignoring child receives SIGKILL after one shared ten-second deadline;
  • every child is reaped once;
  • manager logs contain only fixed lifecycle strings and no sentinel option, credential, code, cookie, or token.

Run:

.venv/bin/pytest tests/test_supervisor.py -k 'lifecycle or child or shutdown or signal or profiles or logs' -q

Expected: FAIL because ProcessManager is absent.

  • Step 6: Implement process-group management

Use:

CLOAK_COMMAND = [
    "/usr/local/bin/cloakserve",
    "--headless=true",
    "--idle-timeout=30",
    "--data-dir=/tmp/cloakserve",
]
STELLOAUTH_COMMAND = ["/usr/local/bin/stelloauth"]

Spawn with subprocess.Popen(command, env=environment, start_new_session=True). The manager must:

  1. remove and recreate /tmp/cloakserve mode 0700;
  2. start CloakBrowser, log fixed messages, and wait at most 60 seconds;
  3. start Stelloauth and wait at most 30 seconds;
  4. poll both children every 250 ms;
  5. treat every pre-shutdown child exit as failure, terminate/reap its sibling, and return the child's nonzero code or 1 for exit 0;
  6. on SIGTERM/SIGINT, signal both groups with SIGTERM, share one ten-second deadline, signal remaining groups with SIGKILL, reap all, and return 0;
  7. emit only the five approved readiness messages, fixed failure categories, and fixed shutdown states.

Expose main() and call sys.exit(main()) only beneath the standard module guard. chmod the file 0755.

  • Step 7: Prove the manager suite is green and commit

Run:

chmod 0755 stelloauth/rootfs/usr/local/bin/addon-supervisor
.venv/bin/pytest tests/test_supervisor.py -q
python3 -m py_compile stelloauth/rootfs/usr/local/bin/addon-supervisor
git diff --check
git diff -- stelloauth/rootfs tests/test_supervisor.py

Expected: all tests PASS and sentinel values are absent from captured logs.

git add stelloauth/rootfs/usr/local/bin/addon-supervisor tests/test_supervisor.py
git commit -m "feat: supervise stelloauth and cloakbrowser"

Task 4: Pinned image and real runtime validation

Files:

  • Create: stelloauth/Dockerfile
  • Create: tests/test_runtime.sh
  • Modify: tests/test_addon_metadata.py

Interfaces:

  • Consumes: both patches and the process manager.

  • Produces: homeassistant-stelloauth-addon:test with only port 8080 exposed and addon-supervisor as its command.

  • Step 1: Add red Dockerfile contract tests

Require the Dockerfile to contain all three approved pins, go test ./..., explicit io.hass labels, EXPOSE 8080, ENTRYPOINT [], and CMD ["/usr/local/bin/addon-supervisor"]. Reject EXPOSE 9222, unpinned latest tags, and COPY of a CloakBrowser binary.

Run:

.venv/bin/pytest tests/test_addon_metadata.py -k dockerfile -q

Expected: FAIL because the Dockerfile is absent.

  • Step 2: Add the two-stage Dockerfile

Use this structure:

# syntax=docker/dockerfile:1.7
FROM golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 AS stelloauth-builder

ARG TARGETARCH
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates git patch \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --filter=blob:none https://github.com/tamcore/stelloauth.git . \
    && git checkout --detach 367d4f8c02a3b072c59142c49dffc129edc8548b \
    && test "$(git rev-parse HEAD)" = "367d4f8c02a3b072c59142c49dffc129edc8548b"
COPY patches/stelloauth-security.patch /tmp/stelloauth-security.patch
RUN git apply --check /tmp/stelloauth-security.patch \
    && git apply /tmp/stelloauth-security.patch \
    && go test ./... \
    && CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" go build -trimpath -ldflags="-s -w" -o /out/stelloauth ./cmd/stelloauth

FROM cloakhq/cloakbrowser:0.5.10@sha256:2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76

ARG BUILD_ARCH
ARG BUILD_DATE
ARG BUILD_DESCRIPTION="Local OAuth worker for Stellantis Vehicles using CloakBrowser"
ARG BUILD_NAME="Stelloauth"
ARG BUILD_REF
ARG BUILD_REPOSITORY="https://git.radixadm.dk/dennis/homeassistant-stelloauth-addon"
ARG BUILD_VERSION="0.1.0"
LABEL io.hass.name="$BUILD_NAME" \
      io.hass.description="$BUILD_DESCRIPTION" \
      io.hass.arch="$BUILD_ARCH" \
      io.hass.type="addon" \
      io.hass.version="$BUILD_VERSION" \
      org.opencontainers.image.created="$BUILD_DATE" \
      org.opencontainers.image.revision="$BUILD_REF" \
      org.opencontainers.image.source="$BUILD_REPOSITORY" \
      org.opencontainers.image.version="$BUILD_VERSION"

USER root
RUN apt-get update \
    && apt-get install -y --no-install-recommends patch \
    && rm -rf /var/lib/apt/lists/*
COPY patches/cloakserve-loopback.patch /tmp/cloakserve-loopback.patch
RUN patch --dry-run -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \
    && patch -p2 -d /usr/local/bin < /tmp/cloakserve-loopback.patch \
    && rm /tmp/cloakserve-loopback.patch \
    && apt-get purge -y --auto-remove patch \
    && rm -rf /var/lib/apt/lists/*
COPY --from=stelloauth-builder /out/stelloauth /usr/local/bin/stelloauth
COPY rootfs/ /
RUN chmod 0755 /usr/local/bin/stelloauth /usr/local/bin/addon-supervisor /usr/local/bin/cloakserve \
    && mkdir -p /data /tmp/cloakserve \
    && chmod 0700 /tmp/cloakserve

EXPOSE 8080
ENTRYPOINT []
CMD ["/usr/local/bin/addon-supervisor"]

First dry-run the patch command against an isolated copy of /usr/local/bin/cloakserve. If the generated patch path needs a different strip count, regenerate the patch with labels a/bin/cloakserve and b/bin/cloakserve so -p2 resolves exactly to cloakserve; do not broaden the runtime patch.

  • Step 3: Prove static validation and the amd64 build

Run:

.venv/bin/pytest tests/test_addon_metadata.py -q
docker buildx build --check stelloauth
docker buildx build --platform linux/amd64 --build-arg BUILD_ARCH=amd64 --load -t homeassistant-stelloauth-addon:test stelloauth
docker image inspect homeassistant-stelloauth-addon:test --format '{{.Architecture}} {{json .Config.Entrypoint}} {{json .Config.Cmd}} {{index .Config.Labels "io.hass.type"}} {{index .Config.Labels "io.hass.version"}}'

Expected: build PASS; inspect prints amd64 [] ["/usr/local/bin/addon-supervisor"] addon 0.1.0.

  • Step 4: Write the real runtime test

Create executable tests/test_runtime.sh with set -Eeuo pipefail, unique container names, a temporary directory, and a trap that removes all created containers. It must:

  1. build the amd64 image unless SKIP_BUILD=1;
  2. write only the three default options to temporary options.json;
  3. run with that file read-only at /data/options.json and map 127.0.0.1 to a random host port for container 8080;
  4. wait at most 90 seconds for the fixed Stelloauth listening log and HTTP 200;
  5. parse /proc/net/tcp inside the container and require 0100007F:240E while rejecting 00000000:240E;
  6. call the real CDP version endpoint with fingerprint runtime-readiness, require webSocketDebuggerUrl, then close the profile;
  7. POST an invalid worker body containing sentinel email/password/token strings and example.invalid, expecting HTTP 400 without an OAuth flow;
  8. scan all container logs and fail on any sentinel;
  9. write docker stats, image size, and docker top output under artifacts/;
  10. stop within ten seconds, assert exit, start a fresh container, and repeat root/CDP checks;
  11. assert docker port reports no mapping for 9222.

Use these exact sentinels:

sentinel-email@example.invalid
SENTINEL_PASSWORD_9a34
SENTINEL_COOKIE_7b21
SENTINEL_OAUTH_CODE_5c88
SENTINEL_ACCESS_TOKEN_1d62
SENTINEL_REFRESH_TOKEN_4e73

Use Python standard-library HTTP and JSON handling inside the script, so host curl and jq are not required.

  • Step 5: Run, diagnose one contract at a time, and prove green

Run:

chmod 0755 tests/test_runtime.sh
SKIP_BUILD=1 tests/test_runtime.sh
.venv/bin/pytest -q
git diff --check

Expected: real CloakBrowser/CDP and Stelloauth checks PASS, CDP is loopback-only, restart/shutdown passes, and logs contain no sentinel. For any failure, add a focused regression assertion to the owning Python test before the minimal fix and rerun this step.

  • Step 6: Verify manifests and arm64 buildability

Run:

docker buildx imagetools inspect golang:1.27.1-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195
docker buildx imagetools inspect cloakhq/cloakbrowser:0.5.10@sha256:2ed5b2d047cbdde22cde7ef1a796526c716aadaa5bccbe1db5ade49282b64a76
docker buildx inspect --bootstrap

Expected: both indexes list linux/amd64 and linux/arm64. When the active builder lists linux/arm64, run:

docker buildx build --platform linux/arm64 --build-arg BUILD_ARCH=aarch64 --load -t homeassistant-stelloauth-addon:arm64-test stelloauth
docker image inspect homeassistant-stelloauth-addon:arm64-test --format '{{.Architecture}}'

Expected with emulation: PASS and arm64. If emulation is absent, record arm64 runtime as skipped while retaining mandatory manifest evidence.

  • Step 7: Commit

    git add stelloauth/Dockerfile tests/test_runtime.sh tests/test_addon_metadata.py git commit -m "test: build and verify addon runtime"

Task 5: Installation documentation and Gitea CI

Files:

  • Create: README.md
  • Create: LICENSE
  • Create: stelloauth/README.md
  • Create: stelloauth/DOCS.md
  • Create: stelloauth/CHANGELOG.md
  • Create: .gitea/workflows/ci.yml
  • Modify: tests/test_addon_metadata.py

Interfaces:

  • Consumes: exact tested behavior and measurements from Task 4.

  • Produces: normal Add-on Store steps and a non-publishing Gitea validation workflow.

  • Step 1: Add red documentation contract tests

Append tests that require:

  • the exact repository URL;
  • http://0031621f-stelloauth:8080/worker;
  • temporary fallback http://192.168.1.20:8080/worker and an instruction to disable the mapping;
  • Brand: Opel and Country: DK;
  • versions v0.6.0 and 0.5.10;
  • the phrase CloakBrowser Binary License;
  • the observed approximately 4 GB free target space;
  • the exact sentence Login-flow RAM: not measured without real MyOpel credentials.

Run:

.venv/bin/pytest tests/test_addon_metadata.py -k documentation -q

Expected: FAIL because documentation files are absent.

  • Step 2: Write the user and maintenance documentation

README.md must include:

  • the one-container/two-process diagram;
  • why one add-on and a source-only local build were selected;
  • repository URL and amd64/aarch64 support;
  • short Add-on Store instructions and a link to stelloauth/DOCS.md;
  • upstream versions, commits, and OCI digests;
  • MIT scope for repository-authored work and the separate CloakBrowser Binary License;
  • no prebuilt image publication and no HAOS installation claim.

stelloauth/DOCS.md must give these numbered Danish steps:

  1. Add the repository URL under Indstillinger → Tilføjelser → Tilføjelsesbutik → ⋮ → Repositorier.
  2. Install Stelloauth, enable Start ved opstart and Watchdog, then start.
  3. Wait for the five fixed readiness messages.
  4. Keep host port disabled. For troubleshooting only, map 8080, test http://192.168.1.20:8080/, then disable it.
  5. Enter exactly http://0031621f-stelloauth:8080/worker in Stellantis Vehicles and explain that /worker is not appended.
  6. Select Brand: Opel and Country: DK and perform OAuth setup.
  7. Explain all three options and fixed one-session behavior.
  8. Explain /tmp/cloakserve, 30-second idle cleanup, no credential persistence, loopback CDP, and redacted logs.
  9. Insert measured idle RAM and image size from artifacts, the approximately 4 GB free-space observation, and the exact unmeasured login-flow sentence.
  10. Cover readiness timeout, invalid URL 400, rate limit 429, repository-hostname changes, disk pressure, temporary port checking, and Supervisor removal.

stelloauth/README.md is a short store description linking to DOCS.md. stelloauth/CHANGELOG.md starts at 0.1.0 and names pins, supervision, internal URL, and hardening. LICENSE is standard MIT text for 2026 Dennis / Radix ApS; the docs explicitly preserve CloakBrowser's separate license.

  • Step 3: Add Gitea CI without publication

Create .gitea/workflows/ci.yml:

name: CI
"on":
  push:
    branches: [main]
  pull_request:
    branches: [main]
permissions:
  contents: read
jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38
        with:
          python-version: "3.12"
          cache: pip
      - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32
        with:
          go-version: "1.27.1"
      - name: Install test dependencies
        run: python -m pip install --requirement requirements-dev.txt
      - name: Validate metadata, patches, and process manager
        run: pytest -q
      - name: Build amd64 image
        run: docker build --build-arg BUILD_ARCH=amd64 --tag homeassistant-stelloauth-addon:test stelloauth
      - name: Exercise runtime
        run: SKIP_BUILD=1 tests/test_runtime.sh

The workflow contains no registry login, push, release, tag, secret reference, or deployment step.

  • Step 4: Prove docs and full release candidate

Run:

.venv/bin/pytest -q
docker buildx build --platform linux/amd64 --build-arg BUILD_ARCH=amd64 --load -t homeassistant-stelloauth-addon:test stelloauth
SKIP_BUILD=1 tests/test_runtime.sh
git diff --check
git status --short

Expected: all tests PASS; runtime evidence is current; only Task 5 files are uncommitted.

  • Step 5: Commit

    git add README.md LICENSE stelloauth/README.md stelloauth/DOCS.md stelloauth/CHANGELOG.md .gitea/workflows/ci.yml tests/test_addon_metadata.py git commit -m "docs: add installation and maintenance guide"

Final Verification Gate

  • Compare every path with the File Map and approved spec; remove caches and scratch files.
  • Run git diff --check 20551e8..HEAD and git diff --stat 20551e8..HEAD.
  • Run .venv/bin/pytest -q and retain exact passed/skipped counts.
  • Run SKIP_BUILD=1 tests/test_runtime.sh and retain image size, idle memory, listener, restart, and log-scan evidence.
  • Inspect the image architecture, command, Home Assistant labels, and exposed-port metadata.
  • Run git status --short --branch; expect a clean local main with no upstream tracking branch because the Gitea repository is still empty.
  • Run git log -n 8 --oneline; verify logical commits and no merge, tag, release, or push.
  • Perform a whole-branch review focused on credentials, URL normalization, CDP exposure, process-group shutdown, patch drift, and CI publication risk. Resolve each concrete finding with a focused test and commit.
  • Stop before git push. Report architecture, all files, exact test evidence, measurements, limitations, installation steps, and http://0031621f-stelloauth:8080/worker.