Add private Home Assistant credentials settings

This commit is contained in:
Gabriel Brown
2026-08-18 06:14:07 -04:00
parent 39a26adcc2
commit de1b8a7673
6 changed files with 780 additions and 2 deletions
@@ -43,10 +43,188 @@ SettingsPage {
return "Home Assistant is unavailable";
}
function saveHomeAssistantConfig(): void {
HomeAssistantConfig.save(
homeUrlInput.text,
homeEntitiesInput.text,
homeTokenInput.text
);
}
Connections {
target: HomeAssistantConfig
function onConfigurationSaved(): void {
homeTokenInput.clear();
homeUrlInput.text = HomeAssistantConfig.url;
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
}
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
SettingRow {
label: "Connection"
detail: HomeAssistantConfig.tokenConfigured
? "A long-lived access token is stored privately"
: "Paste a long-lived access token to connect"
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
}
SettingRow {
label: "Server URL"
detail: "The local or remote address of Home Assistant"
controlWidth: 330
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.07)
border.width: homeUrlInput.activeFocus ? 2 : 1
border.color: homeUrlInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : "transparent"
TextInput {
id: homeUrlInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
activeFocusOnTab: true
text: HomeAssistantConfig.url
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
Text {
anchors.fill: parent
visible: homeUrlInput.text === ""
text: "https://homeassistant.local:8123"
color: Theme.fgMuted
font: homeUrlInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
}
SettingRow {
label: "Access token"
detail: HomeAssistantConfig.tokenConfigured
? "Stored · leave blank to keep it"
: "Create one in your Home Assistant profile"
controlWidth: 330
PasswordField {
id: homeTokenInput
anchors.fill: parent
placeholder: HomeAssistantConfig.tokenConfigured
? "Stored token" : "Long-lived access token"
onAccepted: root.saveHomeAssistantConfig()
}
}
Column {
width: parent.width
spacing: 7
topPadding: 10
bottomPadding: 12
Text {
width: parent.width
text: "Light entities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Text {
width: parent.width
text: "Comma-separated entity IDs. These define the discoverable light catalog."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
height: 72
radius: 10
color: Theme.alpha(Theme.fg, 0.055)
border.width: homeEntitiesInput.activeFocus ? 2 : 1
border.color: homeEntitiesInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
TextEdit {
id: homeEntitiesInput
anchors.fill: parent
anchors.margins: 10
activeFocusOnTab: true
text: HomeAssistantConfig.entities.join(", ")
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: TextEdit.Wrap
clip: true
Text {
anchors.fill: parent
visible: homeEntitiesInput.text === ""
text: "light.living_room, light.kitchen"
color: Theme.fgMuted
font: homeEntitiesInput.font
wrapMode: Text.WordWrap
}
}
}
}
Text {
width: parent.width
visible: HomeAssistantConfig.lastError !== ""
text: HomeAssistantConfig.lastError
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 9
}
SettingRow {
label: "Private configuration"
detail: "Saved with owner-only permissions in Panama's private environment file"
controlWidth: 216
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Clear token"
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
onClicked: HomeAssistantConfig.clearToken()
}
SettingsButton {
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
tone: "accent"
enabled: !HomeAssistantConfig.busy
onClicked: root.saveHomeAssistantConfig()
}
}
}
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Read and atomically update Panama's private Home Assistant settings.
Secret values are accepted only as a single JSON object on stdin and are never
returned. The command line therefore remains safe to inspect with ps(1).
"""
from __future__ import annotations
import fcntl
import json
import os
import pathlib
import re
import shlex
import sys
import tempfile
import urllib.parse
from collections.abc import Mapping, Sequence
from typing import Any
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
ASSIGNMENT = re.compile(
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
class ConfigError(RuntimeError):
"""An error code safe to display without including submitted values."""
def env_path() -> pathlib.Path:
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
return pathlib.Path(override) if override else DEFAULT_ENV
def parse_assignment(raw: str) -> str | None:
try:
parsed = shlex.split(raw, comments=True, posix=True)
except ValueError:
return None
return parsed[0] if len(parsed) == 1 else None
def read_values(path: pathlib.Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return {}
except OSError as error:
raise ConfigError("read-failed") from error
values: dict[str, str] = {}
for line in lines:
match = ASSIGNMENT.match(line)
if not match or match.group("key") not in TARGET_KEYS:
continue
value = parse_assignment(match.group("value"))
if value is not None:
values[match.group("key")] = value
return values
def normalize_url(value: Any) -> str:
if not isinstance(value, str):
raise ConfigError("invalid-url")
normalized = value.strip().rstrip("/")
if not normalized:
return ""
parsed = urllib.parse.urlsplit(normalized)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ConfigError("invalid-url")
return normalized
def normalize_token(value: Any) -> str:
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
raise ConfigError("invalid-token")
return value.strip()
def normalize_entities(value: Any) -> tuple[str, ...]:
if isinstance(value, str):
candidates: Sequence[Any] = re.split(r"[,\n]", value)
elif isinstance(value, list):
candidates = value
else:
raise ConfigError("invalid-entities")
entities: list[str] = []
for candidate in candidates:
if not isinstance(candidate, str):
raise ConfigError("invalid-entities")
entity_id = candidate.strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ConfigError("invalid-entities")
if entity_id not in entities:
entities.append(entity_id)
return tuple(entities)
def public_state(values: Mapping[str, str]) -> dict[str, object]:
try:
url = normalize_url(values.get(KEY_URL, ""))
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
error = ""
except ConfigError as config_error:
url = ""
entities = []
error = str(config_error)
token_configured = bool(values.get(KEY_TOKEN, "").strip())
return {
"ok": error == "",
"configured": bool(url and token_configured),
"tokenConfigured": token_configured,
"url": url,
"entities": entities,
"error": error,
}
def render_updated(original: str, updates: Mapping[str, str]) -> str:
lines = original.splitlines(keepends=True)
rendered: list[str] = []
replaced: set[str] = set()
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = ASSIGNMENT.match(content)
key = match.group("key") if match else ""
if key not in updates:
rendered.append(line)
continue
if key in replaced:
continue
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
replaced.add(key)
if rendered and not rendered[-1].endswith(("\n", "\r")):
rendered[-1] += os.linesep
for key in TARGET_KEYS:
if key in updates and key not in replaced:
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
return "".join(rendered)
def atomic_write(path: pathlib.Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
temporary = pathlib.Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_payload() -> dict[str, Any]:
line = sys.stdin.buffer.readline(1_048_577)
if not line or len(line) > 1_048_576:
raise ConfigError("invalid-payload")
try:
payload = json.loads(line)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ConfigError("invalid-payload") from error
if not isinstance(payload, dict):
raise ConfigError("invalid-payload")
return payload
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
allowed = {"url", "token", "entities"}
if not set(payload).issubset(allowed) or not payload:
raise ConfigError("invalid-payload")
updates: dict[str, str] = {}
if "url" in payload:
updates[KEY_URL] = normalize_url(payload["url"])
if "token" in payload:
updates[KEY_TOKEN] = normalize_token(payload["token"])
if "entities" in payload:
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
lock_path = path.with_name("." + path.name + ".lock")
path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
os.fchmod(lock_fd, 0o600)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
original = path.read_text(encoding="utf-8")
except FileNotFoundError:
original = ""
except OSError as error:
raise ConfigError("read-failed") from error
atomic_write(path, render_updated(original, updates))
finally:
os.close(lock_fd)
result = public_state(read_values(path))
result["ok"] = True
result["error"] = ""
return result
def compact_json(value: Mapping[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "status"
if len(sys.argv) > 2 or command not in {"status", "write"}:
raise ConfigError("usage")
path = env_path()
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
print(compact_json(result))
if __name__ == "__main__":
try:
main()
except ConfigError as error:
print(compact_json({"ok": False, "error": str(error)}))
raise SystemExit(1) from error
except OSError:
print(compact_json({"ok": False, "error": "write-failed"}))
raise SystemExit(1)
@@ -0,0 +1,120 @@
pragma Singleton
// Redacted Home Assistant configuration state. The helper is the only object
// that touches the private env file; QML never receives the stored token.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
property string url: ""
property var entities: []
property bool tokenConfigured: false
property bool configured: false
property string lastError: ""
property string pendingPayload: ""
readonly property bool busy: statusProc.running || writeProc.running
signal configurationSaved
function errorMessage(code: string): string {
switch (code) {
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
case "invalid-token": return "The access token contains unsupported characters.";
case "invalid-entities": return "Entity IDs must look like light.living_room.";
case "read-failed": return "The private configuration file could not be read.";
case "write-failed": return "The private configuration file could not be saved.";
case "invalid-payload": return "The configuration could not be validated.";
default: return code ? "Home Assistant configuration is unavailable." : "";
}
}
function applyResult(text: string, saved: bool): void {
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok !== true) {
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
return;
}
root.url = String(result.url || "");
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.tokenConfigured = result.tokenConfigured === true;
root.configured = result.configured === true;
root.lastError = "";
if (saved) {
root.configurationSaved();
HomeAssistant.refresh();
}
}
function refresh(): void {
if (!statusProc.running && !writeProc.running)
statusProc.running = true;
}
// An empty token means "keep the stored token". Clearing is an explicit
// separate action so editing the URL can never erase a secret by accident.
function save(url: string, entitiesText: string, token: string): bool {
if (root.busy)
return false;
const payload = { url: url, entities: entitiesText };
if (token.trim() !== "")
payload.token = token;
return root.startWrite(payload);
}
function clearToken(): bool {
if (root.busy || !root.tokenConfigured)
return false;
return root.startWrite({ token: "" });
}
function startWrite(payload: var): bool {
root.lastError = "";
root.pendingPayload = JSON.stringify(payload);
writeProc.running = true;
return true;
}
Process {
id: statusProc
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: root.applyResult(this.text, false)
}
onExited: (code, status) => {
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be loaded.";
}
}
Process {
id: writeProc
command: [root.helperPath, "write"]
stdinEnabled: true
stdout: StdioCollector {
id: writeOutput
onStreamFinished: root.applyResult(this.text, true)
}
onStarted: {
writeProc.write(root.pendingPayload + "\n");
root.pendingPayload = "";
}
onExited: (code, status) => {
root.pendingPayload = "";
if (code !== 0 && root.lastError === "")
root.lastError = "Home Assistant configuration could not be saved.";
}
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,37 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
Component.onCompleted: HomeAssistant.fixtureMode = true
IpcHandler {
target: "home-assistant-config-test"
function save(url: string, entities: string): bool {
return HomeAssistantConfig.save(url, entities, "");
}
function clearToken(): bool {
return HomeAssistantConfig.clearToken();
}
function refresh(): void {
HomeAssistantConfig.refresh();
}
function status(): string {
return JSON.stringify({
url: HomeAssistantConfig.url,
entities: HomeAssistantConfig.entities,
tokenConfigured: HomeAssistantConfig.tokenConfigured,
configured: HomeAssistantConfig.configured,
busy: HomeAssistantConfig.busy,
lastError: HomeAssistantConfig.lastError,
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === ""
});
}
}
}
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
env_file="$work/env"
fail() {
printf 'Home Assistant config contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
if declare -F qs_for_test >/dev/null; then
qs_for_test kill >/dev/null 2>&1 || true
fi
rm -rf "$work"
}
trap cleanup EXIT
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
[[ -f "$service" ]] || fail 'credential service is missing'
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
fail 'credential data can reach a process command line'
fi
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
cat >"$env_file" <<'EOF'
# Existing private shell settings must survive byte-for-byte.
export KEEP_ME='untouched value'
export JIRA_CREDENTIALS='unrelated-secret'
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
EOF
chmod 0644 "$env_file"
run_helper() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
}
status="$(run_helper status)" || fail 'status failed for a valid private env file'
jq -e '.configured == true and .tokenConfigured == true
and .url == "https://old.example.test"
and .entities == ["light.old"] and (has("token") | not)' \
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
fail 'status output leaked a secret'
fi
secret='ha-secret-must-never-appear-in-ps-or-output'
payload="$work/payload.json"
jq -cn --arg token "$secret" '{
url: "https://home.example.test/",
token: $token,
entities: ["light.kitchen", "light.desk", "light.kitchen"]
}' >"$payload"
# Keep stdin open long enough to prove the token is absent from the helper's
# process arguments. The secret lives only in the private payload file/stdin.
fifo="$work/input.fifo"
mkfifo "$fifo"
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
helper_pid=$!
for _ in $(seq 1 30); do
kill -0 "$helper_pid" 2>/dev/null && break
sleep 0.05
done
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
fail 'token appeared in the credential helper process arguments'
fi
cp "$payload" "$fifo"
wait "$helper_pid" || fail 'stdin credential write failed'
write_result="$(cat "$work/write.out")"
jq -e '.ok == true and .configured == true and .tokenConfigured == true
and .url == "https://home.example.test"
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
fail 'credential helper output leaked a secret'
fi
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
# Omitting token preserves it; an explicit empty token clears it.
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
| run_helper write >/dev/null || fail 'non-secret update failed'
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
cleared="$(run_helper status)"
jq -e '.configured == false and .tokenConfigured == false
and .url == "https://new.example.test"
and .entities == ["light.office", "light.hall"]' \
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
&& fail 'invalid URL was accepted'
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
# Exercise the actual QML Process.write() boundary with a pre-existing token.
# The IPC carries only non-secret fields; the helper must preserve the token.
config_path="$work/quickshell"
harness="$config_path/home-assistant-config-harness.qml"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
cp "$harness_fixture" "$harness"
printf '%s\n' \
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
"export PANAMA_HOME_ASSISTANT_TOKEN='qml-private-token'" \
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
chmod 0600 "$env_file"
qs_for_test() {
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
qs -p "$harness" "$@"
}
stop_harness() {
qs_for_test kill >/dev/null 2>&1 || true
}
qs_for_test --daemonize >/dev/null
for _ in $(seq 1 60); do
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
sleep 0.1
done
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|| fail 'credential QML harness did not start'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true' \
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
qs_for_test ipc call home-assistant-config-test save \
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|| fail 'QML service refused a non-secret update'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .url == "https://qml-new.example.test"
and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == true and .tokenConfigured == true
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|| fail 'QML service refused token clear'
for _ in $(seq 1 60); do
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
sleep 0.1
done
jq -e '.configured == false and .tokenConfigured == false
and .pendingPayloadEmpty == true and .lastError == ""' \
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
if rg -Fq 'qml-private-token' "$env_file"; then
fail 'QML token clear left the old token in the private env file'
fi
stop_harness
trap - EXIT
cleanup
printf 'Home Assistant config contract: PASS\n'
@@ -105,8 +105,8 @@ assert_contains 'Opens BlueBubbles' "$home_page"
if rg -Fq 'index: model.index' "$home_page"; then
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
fi
if rg -qi 'token|bearer|api/states' "$home_page"; then
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
if rg -qi 'bearer|api/states' "$home_page"; then
fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
fi
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
assert_contains 'signal removeRequested(string id)' "$favorite_card"