cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/architecture-review-findings
goq / scripts / tests / sigil-appliance-status.sh
sigil-appliance-status.sh 24.0K executable
1#!/usr/bin/env bash23set -euo pipefail45script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7sigil="${1:-$repo_dir/target/debug/sigil}"8ffmpeg="$(command -v ffmpeg || true)"910[[ -x "$sigil" ]] || {11 printf 'sigil executable is missing: %s\n' "$sigil" >&212 exit 113}14[[ -n "$ffmpeg" ]] || {15 printf 'ffmpeg is required for the appliance status integration proof\n' >&216 exit 117}1819temp_parent="${TMPDIR:-/tmp}"20temp_parent="${temp_parent%/}"21temp_root="$(mktemp -d "$temp_parent/sigil-appliance-status.XXXXXX")"22host_pid=''2324cleanup() {25 local status=$?26 trap - EXIT INT TERM HUP27 if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then28 kill "$host_pid" 2>/dev/null || true29 wait "$host_pid" 2>/dev/null || true30 fi31 case "$temp_root" in32 "$temp_parent"/sigil-appliance-status.??????) rm -rf -- "$temp_root" ;;33 esac34 exit "$status"35}36trap cleanup EXIT INT TERM HUP3738identity_directory="$temp_root/identity"39state_directory="$temp_root/state"40runtime_directory="$temp_root/runtime"41alternate_runtime_directory="$temp_root/runtime-alternate"42invalid_runtime_directory="$temp_root/invalid-runtime"43config_directory="$temp_root/config"44unsafe_config_directory="$temp_root/config-unsafe"45gamescope_probe_state="$temp_root/gamescope-probe-state"46identity_path="$identity_directory/host.key"47config_path="$config_directory/host.toml"48unsafe_config_path="$unsafe_config_directory/host.toml"49gamescope_probe_config="$config_directory/gamescope-probe.toml"50fake_pw_dump="$temp_root/fake-pw-dump"51fake_render_node="$temp_root/renderD999"52host_log="$temp_root/host.log"53second_log="$temp_root/second.log"54live_status="$temp_root/live-status.json"55legacy_status="$temp_root/legacy-status.json"56stopped_status="$temp_root/stopped-status.json"57reset_status="$temp_root/reset-status.json"58config_show="$temp_root/config-show.json"59config_request="$temp_root/config-request.json"60config_set="$temp_root/config-set.json"61candidate_status="$temp_root/candidate-status.json"6263mkdir -m 0700 \64 "$identity_directory" \65 "$state_directory" \66 "$gamescope_probe_state" \67 "$runtime_directory" \68 "$alternate_runtime_directory"69mkdir -m 0755 "$config_directory"70mkdir -m 0775 "$unsafe_config_directory"71chmod 0775 "$unsafe_config_directory"72mkdir -m 0755 "$invalid_runtime_directory"73"$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"74host_node_id="$(sed -n 's/^node_id=//p' "$temp_root/identity.log")"75[[ -n "$host_node_id" ]] || {76 printf 'identity initialization did not return a node ID\n' >&277 exit 178}7980printf '%s\n' \81 "identity_path = \"$identity_path\"" \82 "state_path = \"$state_directory\"" \83 'source = "test-pattern"' \84 'framerate = 60' \85 'codec = "h264"' \86 'input_mode = "log"' \87 "ffmpeg_path = \"$ffmpeg\"" \88 >"$config_path"89chmod 0600 "$config_path"9091printf '%s\n' \92 '#!/usr/bin/env bash' \93 "printf '[]\\n'" \94 >"$fake_pw_dump"95chmod 0700 "$fake_pw_dump"96false_path="/usr/bin/false"97[[ -x "$false_path" ]]98printf '%s\n' \99 "identity_path = \"$identity_path\"" \100 "state_path = \"$gamescope_probe_state\"" \101 'source = "gamescope-pipewire"' \102 'width = 1280' \103 'height = 800' \104 'framerate = 60' \105 'codec = "h264"' \106 'input_mode = "disabled"' \107 "ffmpeg_path = \"$ffmpeg\"" \108 '' \109 '[gamescope_pipewire]' \110 'node_name = "gamescope"' \111 'media_class = "Video/Source"' \112 'xwayland_display = ":0"' \113 "pw_dump_path = \"$fake_pw_dump\"" \114 "gst_launch_path = \"$false_path\"" \115 "gst_inspect_path = \"$false_path\"" \116 'vaapi_encoder = "vah264enc"' \117 "vaapi_render_node = \"$fake_render_node\"" \118 'rate_control = "cqp"' \119 'quantizer = 24' \120 >"$gamescope_probe_config"121chmod 0600 "$gamescope_probe_config"122123cp "$config_path" "$unsafe_config_path"124chmod 0600 "$unsafe_config_path"125unsafe_config_hash="$(python3 - "$unsafe_config_path" <<'PY'126import hashlib127import pathlib128import sys129print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())130PY131)"132if XDG_RUNTIME_DIR="$runtime_directory" \133 "$sigil" config check --config "$unsafe_config_path" \134 >"$temp_root/unsafe-config-check.out" \135 2>"$temp_root/unsafe-config-check.error"; then136 printf 'config check accepted a group-writable config directory\n' >&2137 exit 1138fi139[[ "$unsafe_config_hash" == "$(python3 - "$unsafe_config_path" <<'PY'140import hashlib141import pathlib142import sys143print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())144PY145)" ]]146147XDG_RUNTIME_DIR="$runtime_directory" \148 "$sigil" serve \149 --config "$config_path" \150 >"$host_log" 2>&1 &151host_pid=$!152153ready='false'154for _ in $(seq 1 40); do155 if grep -Fq 'status=ready' "$host_log"; then156 ready='true'157 break158 fi159 if ! kill -0 "$host_pid" 2>/dev/null; then160 break161 fi162 sleep 0.5163done164if [[ "$ready" != 'true' ]]; then165 printf 'Sigil did not become ready during appliance status proof\n' >&2166 sed -n '1,160p' "$host_log" >&2167 exit 1168fi169170XDG_RUNTIME_DIR="$runtime_directory" \171 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$live_status"172XDG_RUNTIME_DIR="$runtime_directory" \173 "$sigil" appliance status --config "$config_path" --json >"$legacy_status"174XDG_RUNTIME_DIR="$runtime_directory" \175 "$sigil" appliance config show --config "$config_path" --json \176 >"$temp_root/live-config-show.json"177python3 - "$temp_root/live-config-show.json" "$temp_root/live-config-request.json" <<'PY'178import json179import pathlib180import sys181show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))182request = {183 "schema_version": 1,184 "expected_revision": show["revision"],185 "settings": {186 "resolution": {"mode": "native"},187 "framerate": 72,188 "rate_control": None,189 },190}191pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")192PY193if XDG_RUNTIME_DIR="$runtime_directory" \194 "$sigil" appliance config set --config "$config_path" --json \195 <"$temp_root/live-config-request.json" >"$temp_root/live-config-set.out" \196 2>"$temp_root/live-config-set.error"; then197 printf 'config set unexpectedly mutated a live Sigil daemon\n' >&2198 exit 1199fi200python3 - "$temp_root/live-config-set.error" <<'PY'201import json202import pathlib203import sys204error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))205if error != {"schema_version": 1, "error": {"code": "lifecycle_busy"}}:206 raise SystemExit(f"unexpected live config error: {error}")207PY208209if XDG_RUNTIME_DIR="$runtime_directory" \210 "$sigil" serve \211 --identity "$identity_path" \212 --source test-pattern \213 --state-path "$temp_root/other-state" \214 --ffmpeg "$ffmpeg" \215 --max-runtime-seconds 1 \216 >"$second_log" 2>&1; then217 printf 'a second per-user Sigil daemon unexpectedly started\n' >&2218 exit 1219fi220grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' "$second_log"221222if XDG_RUNTIME_DIR="$runtime_directory" \223 "$sigil" capture probe \224 --source gamescope-pipewire \225 --config "$gamescope_probe_config" \226 --frames 1 \227 >"$temp_root/contended-capture.out" \228 2>"$temp_root/contended-capture.error"; then229 printf 'Gamescope capture probe overlapped a live Sigil daemon\n' >&2230 exit 1231fi232grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \233 "$temp_root/contended-capture.error"234if grep -Fq 'inspecting VAAPI render node' "$temp_root/contended-capture.error"; then235 printf 'contended capture reached hardware preflight before acquiring the lifecycle lock\n' >&2236 exit 1237fi238239python3 - "$live_status" "$host_node_id" <<'PY'240import json241import pathlib242import sys243244path = pathlib.Path(sys.argv[1])245raw = path.read_text(encoding="utf-8")246status = json.loads(raw)247host_node_id = sys.argv[2]248249if status.get("schema_version") != 2:250 raise SystemExit("unexpected appliance status schema")251if status.get("overall") != "ready":252 raise SystemExit(f"unexpected live overall state: {status.get('overall')}")253if status.get("runtime", {}).get("daemon") != "ready":254 raise SystemExit("live daemon state is not ready")255if status.get("runtime", {}).get("session") != "inactive":256 raise SystemExit("live proof unexpectedly reports a session")257if not status.get("runtime", {}).get("loaded_config_revision", "").startswith("sha256:"):258 raise SystemExit("live runtime is not bound to an exact config revision")259if status.get("runtime", {}).get("reached_ready") is not True:260 raise SystemExit("live runtime did not retain its ready transition")261if status.get("enrollment", {}).get("state") != "none":262 raise SystemExit("direct proof unexpectedly reports an enrollment")263if host_node_id in raw:264 raise SystemExit("appliance status leaked the complete host node ID")265fingerprint = status.get("identity", {}).get("host_fingerprint", "")266if len(fingerprint) != 17 or "…" not in fingerprint:267 raise SystemExit("host fingerprint is not bounded and redacted")268PY269270python3 - "$legacy_status" <<'PY'271import json272import pathlib273import sys274275status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))276if status.get("schema_version") != 1:277 raise SystemExit("default appliance status did not preserve schema v1")278if "revision" in status.get("config", {}) or "pending_transaction" in status.get("config", {}):279 raise SystemExit("status v1 leaked status-v2 config fields")280runtime = status.get("runtime", {})281if any(field in runtime for field in ("instance_id", "loaded_config_revision", "reached_ready")):282 raise SystemExit("status v1 leaked status-v2 runtime fields")283PY284285host_fingerprint="$(python3 - "$live_status" <<'PY'286import json287import pathlib288import sys289290print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["identity"]["host_fingerprint"])291PY292)"293294if env -u XDG_RUNTIME_DIR \295 "$sigil" appliance enrollment-reset \296 --config "$config_path" \297 --expected-host-fingerprint "$host_fingerprint" \298 --json \299 >"$temp_root/live-reset.log" 2>&1; then300 printf 'enrollment reset unexpectedly mutated a live Sigil daemon\n' >&2301 exit 1302fi303grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \304 "$temp_root/live-reset.log"305306if ! kill -TERM "$host_pid" 2>/dev/null; then307 printf 'Sigil exited before the appliance status proof requested shutdown\n' >&2308 sed -n '1,160p' "$host_log" >&2309 exit 1310fi311if ! wait "$host_pid"; then312 printf 'Sigil did not shut down cleanly during the appliance status proof\n' >&2313 sed -n '1,160p' "$host_log" >&2314 exit 1315fi316host_pid=''317if XDG_RUNTIME_DIR="$runtime_directory" \318 "$sigil" capture probe \319 --source gamescope-pipewire \320 --config "$gamescope_probe_config" \321 --frames 1 \322 >"$temp_root/released-capture.out" \323 2>"$temp_root/released-capture.error"; then324 printf 'fake Gamescope capture unexpectedly completed\n' >&2325 exit 1326fi327grep -Fq "inspecting VAAPI render node $fake_render_node" \328 "$temp_root/released-capture.error"329XDG_RUNTIME_DIR="$runtime_directory" \330 "$sigil" serve \331 --identity "$identity_path" \332 --source test-pattern \333 --state-path "$temp_root/post-probe-state" \334 --ffmpeg "$ffmpeg" \335 --max-runtime-seconds 1 \336 >"$temp_root/post-probe-serve.out" 2>&1337grep -Fq 'status=ready' "$temp_root/post-probe-serve.out"338[[ ! -e "$state_directory/authorization-v1.json" ]]339if XDG_RUNTIME_DIR="$runtime_directory" \340 "$sigil" appliance enrollment-reset \341 --config "$config_path" \342 --expected-host-fingerprint '00000000…00000000' \343 --json \344 >"$temp_root/mismatch-reset.log" 2>&1; then345 printf 'enrollment reset accepted the wrong host fingerprint\n' >&2346 exit 1347fi348[[ ! -e "$state_directory/authorization-v1.json" ]]349if XDG_RUNTIME_DIR="$invalid_runtime_directory" \350 "$sigil" appliance enrollment-reset \351 --config "$config_path" \352 --expected-host-fingerprint "$host_fingerprint" \353 --json \354 >"$temp_root/invalid-runtime-reset.log" 2>&1; then355 printf 'enrollment reset ignored an unsafe XDG runtime root\n' >&2356 exit 1357fi358[[ ! -e "$state_directory/authorization-v1.json" ]]359env -u XDG_RUNTIME_DIR \360 "$sigil" appliance enrollment-reset \361 --config "$config_path" \362 --expected-host-fingerprint "$host_fingerprint" \363 --json >"$reset_status"364XDG_RUNTIME_DIR="$runtime_directory" \365 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$stopped_status"366367python3 - "$stopped_status" "$reset_status" "$host_node_id" <<'PY'368import json369import pathlib370import sys371372status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))373reset_raw = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")374reset = json.loads(reset_raw)375host_node_id = sys.argv[3]376runtime = status.get("runtime", {})377if runtime.get("daemon") != "stopped":378 raise SystemExit(f"clean shutdown did not publish stopped: {runtime}")379if runtime.get("session") != "inactive":380 raise SystemExit("stopped daemon did not report an inactive session")381if runtime.get("uptime_ms") is not None:382 raise SystemExit("stopped daemon retained a live uptime")383if not runtime.get("instance_id"):384 raise SystemExit("fresh stopped state omitted its daemon instance")385if runtime.get("reached_ready") is not True:386 raise SystemExit("fresh stopped state forgot that it reached ready")387if reset.get("schema_version") != 1 or reset.get("operation") != "enrollment_reset":388 raise SystemExit("unexpected enrollment reset schema")389if reset.get("had_enrollment") is not False:390 raise SystemExit("empty enrollment reset reported an enrolled Portal")391if reset.get("current_epoch") != reset.get("previous_epoch") + 1:392 raise SystemExit("enrollment reset did not advance exactly one epoch")393if reset.get("invitations_invalidated") is not True:394 raise SystemExit("enrollment reset did not report invitation invalidation")395if host_node_id in reset_raw:396 raise SystemExit("enrollment reset leaked the complete host node ID")397if status.get("enrollment", {}).get("epoch") != reset.get("current_epoch"):398 raise SystemExit("status does not reflect the enrollment reset epoch")399PY400401XDG_RUNTIME_DIR="$runtime_directory" \402 "$sigil" appliance config show --config "$config_path" --json >"$config_show"403python3 - "$config_show" "$config_request" <<'PY'404import json405import pathlib406import sys407408show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))409if show.get("schema_version") != 1 or show.get("pending_transaction") is not None:410 raise SystemExit("unexpected initial config projection")411request = {412 "schema_version": 1,413 "expected_revision": show["revision"],414 "settings": {415 "resolution": {"mode": "native"},416 "framerate": 72,417 "rate_control": None,418 },419}420pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")421PY422423base_hash="$(python3 - "$config_path" <<'PY'424import hashlib425import pathlib426import sys427print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())428PY429)"430XDG_RUNTIME_DIR="$runtime_directory" \431 "$sigil" appliance config validate --config "$config_path" --json \432 <"$config_request" >"$temp_root/config-validate.json"433[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'434import hashlib435import pathlib436import sys437print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())438PY439)" ]]440if env -u XDG_RUNTIME_DIR \441 "$sigil" appliance config set \442 --config "$config_path" --runtime-dir "$invalid_runtime_directory" --json \443 <"$config_request" >"$temp_root/invalid-runtime-set.out" \444 2>"$temp_root/invalid-runtime-set.error"; then445 printf 'config set accepted an unsafe explicit runtime root\n' >&2446 exit 1447fi448python3 - "$temp_root/invalid-runtime-set.error" <<'PY'449import json450import pathlib451import sys452error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))453if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:454 raise SystemExit(f"unexpected unsafe runtime-root error: {error}")455PY456[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'457import hashlib458import pathlib459import sys460print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())461PY462)" ]]463[[ ! -e "$state_directory/config-transaction-v1.json" ]]464465if env -u XDG_RUNTIME_DIR \466 "$sigil" appliance config set --config "$config_path" --json \467 <"$config_request" >"$temp_root/missing-runtime-set.out" \468 2>"$temp_root/missing-runtime-set.error"; then469 printf 'config set unexpectedly accepted no runtime authority\n' >&2470 exit 1471fi472python3 - "$temp_root/missing-runtime-set.error" <<'PY'473import json474import pathlib475import sys476error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))477if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:478 raise SystemExit(f"unexpected missing runtime-root error: {error}")479PY480[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'481import hashlib482import pathlib483import sys484print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())485PY486)" ]]487[[ ! -e "$state_directory/config-transaction-v1.json" ]]488489env -u XDG_RUNTIME_DIR \490 "$sigil" appliance config set \491 --config "$config_path" --runtime-dir "$runtime_directory" --json \492 <"$config_request" >"$config_set"493494transaction="$(python3 - "$config_set" <<'PY'495import json496import pathlib497import sys498result = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))499if result.get("changed") is not True or result.get("restart_required") is not True:500 raise SystemExit("config set did not require candidate validation")501print(result["transaction"])502PY503)"504505XDG_RUNTIME_DIR="$runtime_directory" \506 "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \507 >"$temp_root/candidate.log" 2>&1508XDG_RUNTIME_DIR="$runtime_directory" \509 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$candidate_status"510candidate_instance="$(python3 - "$candidate_status" "$config_set" <<'PY'511import json512import pathlib513import sys514status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))515staged = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))516runtime = status["runtime"]517if runtime.get("daemon") != "stopped" or runtime.get("reached_ready") is not True:518 raise SystemExit("candidate did not stop cleanly after reaching ready")519if runtime.get("loaded_config_revision") != staged.get("candidate_revision"):520 raise SystemExit("candidate runtime revision does not match staged config")521if status.get("config", {}).get("pending_transaction", {}).get("transaction") != staged.get("transaction"):522 raise SystemExit("status omitted the pending config transaction")523print(runtime["instance_id"])524PY525)"526mkdir -m 0700 "$alternate_runtime_directory/sigil-spark"527cp "$runtime_directory/sigil-spark/daemon-status-v1.json" \528 "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"529chmod 0600 "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"530cp "$config_path" "$temp_root/pre-mismatch-config.toml"531cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"532cp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"533cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"534if env -u XDG_RUNTIME_DIR \535 "$sigil" appliance config commit \536 --config "$config_path" \537 --runtime-dir "$alternate_runtime_directory" \538 --transaction "$transaction" \539 --expected-instance "$candidate_instance" \540 --json >"$temp_root/mismatched-runtime-commit.out" \541 2>"$temp_root/mismatched-runtime-commit.error"; then542 printf 'config commit accepted candidate evidence from a different runtime namespace\n' >&2543 exit 1544fi545python3 - "$temp_root/mismatched-runtime-commit.error" <<'PY'546import json547import pathlib548import sys549error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))550if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:551 raise SystemExit(f"unexpected mismatched runtime error: {error}")552PY553cmp "$config_path" "$temp_root/pre-mismatch-config.toml"554cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"555cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"556cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"557env -u XDG_RUNTIME_DIR \558 "$sigil" appliance config commit \559 --config "$config_path" \560 --runtime-dir "$runtime_directory" \561 --transaction "$transaction" \562 --expected-instance "$candidate_instance" \563 --json >"$temp_root/config-commit.json"564XDG_RUNTIME_DIR="$runtime_directory" \565 "$sigil" appliance config show --config "$config_path" --json \566 >"$temp_root/committed-show.json"567568python3 - "$temp_root/committed-show.json" "$temp_root/rollback-request.json" <<'PY'569import json570import pathlib571import sys572show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))573if show.get("pending_transaction") is not None or show["settings"]["framerate"] != 72:574 raise SystemExit("committed config state is incorrect")575request = {576 "schema_version": 1,577 "expected_revision": show["revision"],578 "settings": {579 "resolution": {"mode": "fixed", "width": 1280, "height": 800},580 "framerate": 60,581 "rate_control": None,582 },583}584pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")585PY586committed_hash="$(python3 - "$config_path" <<'PY'587import hashlib588import pathlib589import sys590print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())591PY592)"593XDG_RUNTIME_DIR="$runtime_directory" \594 "$sigil" appliance config set --config "$config_path" --json \595 <"$temp_root/rollback-request.json" >"$temp_root/rollback-set.json"596rollback_transaction="$(python3 - "$temp_root/rollback-set.json" <<'PY'597import json598import pathlib599import sys600print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["transaction"])601PY602)"603python3 - "$state_directory/config-transaction-v1.json" <<'PY'604import json605import pathlib606import sys607path = pathlib.Path(sys.argv[1])608journal = json.loads(path.read_text(encoding="utf-8"))609journal.pop("runtime_directory_id", None)610path.write_text(json.dumps(journal) + "\n", encoding="utf-8")611PY612cp "$config_path" "$temp_root/pre-legacy-commit-config.toml"613cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"614cp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"615cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"616if env -u XDG_RUNTIME_DIR \617 "$sigil" appliance config commit \618 --config "$config_path" \619 --runtime-dir "$runtime_directory" \620 --transaction "$rollback_transaction" \621 --expected-instance "$candidate_instance" \622 --json >"$temp_root/legacy-commit.out" \623 2>"$temp_root/legacy-commit.error"; then624 printf 'config commit accepted a legacy transaction without runtime binding\n' >&2625 exit 1626fi627python3 - "$temp_root/legacy-commit.error" <<'PY'628import json629import pathlib630import sys631error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))632if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:633 raise SystemExit(f"unexpected legacy transaction commit error: {error}")634PY635cmp "$config_path" "$temp_root/pre-legacy-commit-config.toml"636cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"637cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"638cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"639env -u XDG_RUNTIME_DIR \640 "$sigil" appliance config rollback \641 --config "$config_path" --transaction "$rollback_transaction" --json \642 >"$temp_root/config-rollback.json"643[[ "$committed_hash" == "$(python3 - "$config_path" <<'PY'644import hashlib645import pathlib646import sys647print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())648PY649)" ]]650[[ ! -e "$state_directory/config-transaction-v1.json" ]]651[[ ! -e "$state_directory/config-base-v1.toml" ]]652[[ ! -e "$state_directory/config-candidate-v1.toml" ]]653654if env -u XDG_RUNTIME_DIR \655 "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \656 >"$temp_root/missing-runtime.log" 2>&1; then657 printf 'configured Sigil unexpectedly started without XDG_RUNTIME_DIR\n' >&2658 exit 1659fi660grep -Fq 'configured Sigil service requires a valid XDG_RUNTIME_DIR' \661 "$temp_root/missing-runtime.log"662663printf 'sigil appliance status integration proof passed\n'