From f288c7b5ff6f8bdf53312c297705f2d9534824e4 Mon Sep 17 00:00:00 2001 From: Dennis Juhler Aagaard Date: Thu, 24 Sep 2026 12:38:45 +0200 Subject: [PATCH] docs: add stelloauth implementation plan --- ...6-09-24-home-assistant-stelloauth-addon.md | 831 ++++++++++++++++++ 1 file changed, 831 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-24-home-assistant-stelloauth-addon.md diff --git a/docs/superpowers/plans/2026-09-24-home-assistant-stelloauth-addon.md b/docs/superpowers/plans/2026-09-24-home-assistant-stelloauth-addon.md new file mode 100644 index 0000000..30472df --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-home-assistant-stelloauth-addon.md @@ -0,0 +1,831 @@ +# 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.