cat / goq
clone

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

fix/release-manifest-language
goq / scripts / tests / sigil-appliance-status.sh
sigil-appliance-status.sh 24.1K 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%/}"21# Keep the owner-only authorization socket below Darwin's sockaddr_un limit.22if [[ "$(uname -s)" == Darwin ]]; then23 temp_parent=/tmp24fi25temp_root="$(mktemp -d "$temp_parent/sigil-appliance-status.XXXXXX")"26host_pid=''2728cleanup() {29 local status=$?30 trap - EXIT INT TERM HUP31 if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then32 kill "$host_pid" 2>/dev/null || true33 wait "$host_pid" 2>/dev/null || true34 fi35 case "$temp_root" in36 "$temp_parent"/sigil-appliance-status.??????) rm -rf -- "$temp_root" ;;37 esac38 exit "$status"39}40trap cleanup EXIT INT TERM HUP4142identity_directory="$temp_root/identity"43state_directory="$temp_root/state"44runtime_directory="$temp_root/runtime"45alternate_runtime_directory="$temp_root/runtime-alternate"46invalid_runtime_directory="$temp_root/invalid-runtime"47config_directory="$temp_root/config"48unsafe_config_directory="$temp_root/config-unsafe"49gamescope_probe_state="$temp_root/gamescope-probe-state"50identity_path="$identity_directory/host.key"51config_path="$config_directory/host.toml"52unsafe_config_path="$unsafe_config_directory/host.toml"53gamescope_probe_config="$config_directory/gamescope-probe.toml"54fake_pw_dump="$temp_root/fake-pw-dump"55fake_render_node="$temp_root/renderD999"56host_log="$temp_root/host.log"57second_log="$temp_root/second.log"58live_status="$temp_root/live-status.json"59legacy_status="$temp_root/legacy-status.json"60stopped_status="$temp_root/stopped-status.json"61reset_status="$temp_root/reset-status.json"62config_show="$temp_root/config-show.json"63config_request="$temp_root/config-request.json"64config_set="$temp_root/config-set.json"65candidate_status="$temp_root/candidate-status.json"6667mkdir -m 0700 \68 "$identity_directory" \69 "$state_directory" \70 "$gamescope_probe_state" \71 "$runtime_directory" \72 "$alternate_runtime_directory"73mkdir -m 0755 "$config_directory"74mkdir -m 0775 "$unsafe_config_directory"75chmod 0775 "$unsafe_config_directory"76mkdir -m 0755 "$invalid_runtime_directory"77"$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"78host_node_id="$(sed -n 's/^node_id=//p' "$temp_root/identity.log")"79[[ -n "$host_node_id" ]] || {80 printf 'identity initialization did not return a node ID\n' >&281 exit 182}8384printf '%s\n' \85 "identity_path = \"$identity_path\"" \86 "state_path = \"$state_directory\"" \87 'source = "test-pattern"' \88 'framerate = 60' \89 'codec = "h264"' \90 'input_mode = "log"' \91 "ffmpeg_path = \"$ffmpeg\"" \92 >"$config_path"93chmod 0600 "$config_path"9495printf '%s\n' \96 '#!/usr/bin/env bash' \97 "printf '[]\\n'" \98 >"$fake_pw_dump"99chmod 0700 "$fake_pw_dump"100false_path="/usr/bin/false"101[[ -x "$false_path" ]]102printf '%s\n' \103 "identity_path = \"$identity_path\"" \104 "state_path = \"$gamescope_probe_state\"" \105 'source = "gamescope-pipewire"' \106 'width = 1280' \107 'height = 800' \108 'framerate = 60' \109 'codec = "h264"' \110 'input_mode = "disabled"' \111 "ffmpeg_path = \"$ffmpeg\"" \112 '' \113 '[gamescope_pipewire]' \114 'node_name = "gamescope"' \115 'media_class = "Video/Source"' \116 'xwayland_display = ":0"' \117 "pw_dump_path = \"$fake_pw_dump\"" \118 "gst_launch_path = \"$false_path\"" \119 "gst_inspect_path = \"$false_path\"" \120 'vaapi_encoder = "vah264enc"' \121 "vaapi_render_node = \"$fake_render_node\"" \122 'rate_control = "cqp"' \123 'quantizer = 24' \124 >"$gamescope_probe_config"125chmod 0600 "$gamescope_probe_config"126127cp "$config_path" "$unsafe_config_path"128chmod 0600 "$unsafe_config_path"129unsafe_config_hash="$(python3 - "$unsafe_config_path" <<'PY'130import hashlib131import pathlib132import sys133print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())134PY135)"136if XDG_RUNTIME_DIR="$runtime_directory" \137 "$sigil" config check --config "$unsafe_config_path" \138 >"$temp_root/unsafe-config-check.out" \139 2>"$temp_root/unsafe-config-check.error"; then140 printf 'config check accepted a group-writable config directory\n' >&2141 exit 1142fi143[[ "$unsafe_config_hash" == "$(python3 - "$unsafe_config_path" <<'PY'144import hashlib145import pathlib146import sys147print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())148PY149)" ]]150151XDG_RUNTIME_DIR="$runtime_directory" \152 "$sigil" serve \153 --config "$config_path" \154 >"$host_log" 2>&1 &155host_pid=$!156157ready='false'158for _ in $(seq 1 40); do159 if grep -Fq 'status=ready' "$host_log"; then160 ready='true'161 break162 fi163 if ! kill -0 "$host_pid" 2>/dev/null; then164 break165 fi166 sleep 0.5167done168if [[ "$ready" != 'true' ]]; then169 printf 'Sigil did not become ready during appliance status proof\n' >&2170 sed -n '1,160p' "$host_log" >&2171 exit 1172fi173174XDG_RUNTIME_DIR="$runtime_directory" \175 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$live_status"176XDG_RUNTIME_DIR="$runtime_directory" \177 "$sigil" appliance status --config "$config_path" --json >"$legacy_status"178XDG_RUNTIME_DIR="$runtime_directory" \179 "$sigil" appliance config show --config "$config_path" --json \180 >"$temp_root/live-config-show.json"181python3 - "$temp_root/live-config-show.json" "$temp_root/live-config-request.json" <<'PY'182import json183import pathlib184import sys185show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))186request = {187 "schema_version": 1,188 "expected_revision": show["revision"],189 "settings": {190 "resolution": {"mode": "native"},191 "framerate": 72,192 "rate_control": None,193 },194}195pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")196PY197if XDG_RUNTIME_DIR="$runtime_directory" \198 "$sigil" appliance config set --config "$config_path" --json \199 <"$temp_root/live-config-request.json" >"$temp_root/live-config-set.out" \200 2>"$temp_root/live-config-set.error"; then201 printf 'config set unexpectedly mutated a live Sigil daemon\n' >&2202 exit 1203fi204python3 - "$temp_root/live-config-set.error" <<'PY'205import json206import pathlib207import sys208error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))209if error != {"schema_version": 1, "error": {"code": "lifecycle_busy"}}:210 raise SystemExit(f"unexpected live config error: {error}")211PY212213if XDG_RUNTIME_DIR="$runtime_directory" \214 "$sigil" serve \215 --identity "$identity_path" \216 --source test-pattern \217 --state-path "$temp_root/other-state" \218 --ffmpeg "$ffmpeg" \219 --max-runtime-seconds 1 \220 >"$second_log" 2>&1; then221 printf 'a second per-user Sigil daemon unexpectedly started\n' >&2222 exit 1223fi224grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' "$second_log"225226if XDG_RUNTIME_DIR="$runtime_directory" \227 "$sigil" capture probe \228 --source gamescope-pipewire \229 --config "$gamescope_probe_config" \230 --frames 1 \231 >"$temp_root/contended-capture.out" \232 2>"$temp_root/contended-capture.error"; then233 printf 'Gamescope capture probe overlapped a live Sigil daemon\n' >&2234 exit 1235fi236grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \237 "$temp_root/contended-capture.error"238if grep -Fq 'inspecting VAAPI render node' "$temp_root/contended-capture.error"; then239 printf 'contended capture reached hardware preflight before acquiring the lifecycle lock\n' >&2240 exit 1241fi242243python3 - "$live_status" "$host_node_id" <<'PY'244import json245import pathlib246import sys247248path = pathlib.Path(sys.argv[1])249raw = path.read_text(encoding="utf-8")250status = json.loads(raw)251host_node_id = sys.argv[2]252253if status.get("schema_version") != 2:254 raise SystemExit("unexpected appliance status schema")255if status.get("overall") != "ready":256 raise SystemExit(f"unexpected live overall state: {status.get('overall')}")257if status.get("runtime", {}).get("daemon") != "ready":258 raise SystemExit("live daemon state is not ready")259if status.get("runtime", {}).get("session") != "inactive":260 raise SystemExit("live proof unexpectedly reports a session")261if not status.get("runtime", {}).get("loaded_config_revision", "").startswith("sha256:"):262 raise SystemExit("live runtime is not bound to an exact config revision")263if status.get("runtime", {}).get("reached_ready") is not True:264 raise SystemExit("live runtime did not retain its ready transition")265if status.get("enrollment", {}).get("state") != "none":266 raise SystemExit("direct proof unexpectedly reports an enrollment")267if host_node_id in raw:268 raise SystemExit("appliance status leaked the complete host node ID")269fingerprint = status.get("identity", {}).get("host_fingerprint", "")270if len(fingerprint) != 17 or "…" not in fingerprint:271 raise SystemExit("host fingerprint is not bounded and redacted")272PY273274python3 - "$legacy_status" <<'PY'275import json276import pathlib277import sys278279status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))280if status.get("schema_version") != 1:281 raise SystemExit("default appliance status did not preserve schema v1")282if "revision" in status.get("config", {}) or "pending_transaction" in status.get("config", {}):283 raise SystemExit("status v1 leaked status-v2 config fields")284runtime = status.get("runtime", {})285if any(field in runtime for field in ("instance_id", "loaded_config_revision", "reached_ready")):286 raise SystemExit("status v1 leaked status-v2 runtime fields")287PY288289host_fingerprint="$(python3 - "$live_status" <<'PY'290import json291import pathlib292import sys293294print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["identity"]["host_fingerprint"])295PY296)"297298if env -u XDG_RUNTIME_DIR \299 "$sigil" appliance enrollment-reset \300 --config "$config_path" \301 --expected-host-fingerprint "$host_fingerprint" \302 --json \303 >"$temp_root/live-reset.log" 2>&1; then304 printf 'enrollment reset unexpectedly mutated a live Sigil daemon\n' >&2305 exit 1306fi307grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \308 "$temp_root/live-reset.log"309310if ! kill -TERM "$host_pid" 2>/dev/null; then311 printf 'Sigil exited before the appliance status proof requested shutdown\n' >&2312 sed -n '1,160p' "$host_log" >&2313 exit 1314fi315if ! wait "$host_pid"; then316 printf 'Sigil did not shut down cleanly during the appliance status proof\n' >&2317 sed -n '1,160p' "$host_log" >&2318 exit 1319fi320host_pid=''321if XDG_RUNTIME_DIR="$runtime_directory" \322 "$sigil" capture probe \323 --source gamescope-pipewire \324 --config "$gamescope_probe_config" \325 --frames 1 \326 >"$temp_root/released-capture.out" \327 2>"$temp_root/released-capture.error"; then328 printf 'fake Gamescope capture unexpectedly completed\n' >&2329 exit 1330fi331grep -Fq "inspecting VAAPI render node $fake_render_node" \332 "$temp_root/released-capture.error"333XDG_RUNTIME_DIR="$runtime_directory" \334 "$sigil" serve \335 --identity "$identity_path" \336 --source test-pattern \337 --state-path "$temp_root/post-probe-state" \338 --ffmpeg "$ffmpeg" \339 --max-runtime-seconds 1 \340 >"$temp_root/post-probe-serve.out" 2>&1341grep -Fq 'status=ready' "$temp_root/post-probe-serve.out"342[[ ! -e "$state_directory/authorization-v1.json" ]]343if XDG_RUNTIME_DIR="$runtime_directory" \344 "$sigil" appliance enrollment-reset \345 --config "$config_path" \346 --expected-host-fingerprint '00000000…00000000' \347 --json \348 >"$temp_root/mismatch-reset.log" 2>&1; then349 printf 'enrollment reset accepted the wrong host fingerprint\n' >&2350 exit 1351fi352[[ ! -e "$state_directory/authorization-v1.json" ]]353if XDG_RUNTIME_DIR="$invalid_runtime_directory" \354 "$sigil" appliance enrollment-reset \355 --config "$config_path" \356 --expected-host-fingerprint "$host_fingerprint" \357 --json \358 >"$temp_root/invalid-runtime-reset.log" 2>&1; then359 printf 'enrollment reset ignored an unsafe XDG runtime root\n' >&2360 exit 1361fi362[[ ! -e "$state_directory/authorization-v1.json" ]]363env -u XDG_RUNTIME_DIR \364 "$sigil" appliance enrollment-reset \365 --config "$config_path" \366 --expected-host-fingerprint "$host_fingerprint" \367 --json >"$reset_status"368XDG_RUNTIME_DIR="$runtime_directory" \369 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$stopped_status"370371python3 - "$stopped_status" "$reset_status" "$host_node_id" <<'PY'372import json373import pathlib374import sys375376status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))377reset_raw = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")378reset = json.loads(reset_raw)379host_node_id = sys.argv[3]380runtime = status.get("runtime", {})381if runtime.get("daemon") != "stopped":382 raise SystemExit(f"clean shutdown did not publish stopped: {runtime}")383if runtime.get("session") != "inactive":384 raise SystemExit("stopped daemon did not report an inactive session")385if runtime.get("uptime_ms") is not None:386 raise SystemExit("stopped daemon retained a live uptime")387if not runtime.get("instance_id"):388 raise SystemExit("fresh stopped state omitted its daemon instance")389if runtime.get("reached_ready") is not True:390 raise SystemExit("fresh stopped state forgot that it reached ready")391if reset.get("schema_version") != 1 or reset.get("operation") != "enrollment_reset":392 raise SystemExit("unexpected enrollment reset schema")393if reset.get("had_enrollment") is not False:394 raise SystemExit("empty enrollment reset reported an enrolled Portal")395if reset.get("current_epoch") != reset.get("previous_epoch") + 1:396 raise SystemExit("enrollment reset did not advance exactly one epoch")397if reset.get("invitations_invalidated") is not True:398 raise SystemExit("enrollment reset did not report invitation invalidation")399if host_node_id in reset_raw:400 raise SystemExit("enrollment reset leaked the complete host node ID")401if status.get("enrollment", {}).get("epoch") != reset.get("current_epoch"):402 raise SystemExit("status does not reflect the enrollment reset epoch")403PY404405XDG_RUNTIME_DIR="$runtime_directory" \406 "$sigil" appliance config show --config "$config_path" --json >"$config_show"407python3 - "$config_show" "$config_request" <<'PY'408import json409import pathlib410import sys411412show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))413if show.get("schema_version") != 1 or show.get("pending_transaction") is not None:414 raise SystemExit("unexpected initial config projection")415request = {416 "schema_version": 1,417 "expected_revision": show["revision"],418 "settings": {419 "resolution": {"mode": "native"},420 "framerate": 72,421 "rate_control": None,422 },423}424pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")425PY426427base_hash="$(python3 - "$config_path" <<'PY'428import hashlib429import pathlib430import sys431print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())432PY433)"434XDG_RUNTIME_DIR="$runtime_directory" \435 "$sigil" appliance config validate --config "$config_path" --json \436 <"$config_request" >"$temp_root/config-validate.json"437[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'438import hashlib439import pathlib440import sys441print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())442PY443)" ]]444if env -u XDG_RUNTIME_DIR \445 "$sigil" appliance config set \446 --config "$config_path" --runtime-dir "$invalid_runtime_directory" --json \447 <"$config_request" >"$temp_root/invalid-runtime-set.out" \448 2>"$temp_root/invalid-runtime-set.error"; then449 printf 'config set accepted an unsafe explicit runtime root\n' >&2450 exit 1451fi452python3 - "$temp_root/invalid-runtime-set.error" <<'PY'453import json454import pathlib455import sys456error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))457if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:458 raise SystemExit(f"unexpected unsafe runtime-root error: {error}")459PY460[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'461import hashlib462import pathlib463import sys464print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())465PY466)" ]]467[[ ! -e "$state_directory/config-transaction-v1.json" ]]468469if env -u XDG_RUNTIME_DIR \470 "$sigil" appliance config set --config "$config_path" --json \471 <"$config_request" >"$temp_root/missing-runtime-set.out" \472 2>"$temp_root/missing-runtime-set.error"; then473 printf 'config set unexpectedly accepted no runtime authority\n' >&2474 exit 1475fi476python3 - "$temp_root/missing-runtime-set.error" <<'PY'477import json478import pathlib479import sys480error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))481if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:482 raise SystemExit(f"unexpected missing runtime-root error: {error}")483PY484[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'485import hashlib486import pathlib487import sys488print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())489PY490)" ]]491[[ ! -e "$state_directory/config-transaction-v1.json" ]]492493env -u XDG_RUNTIME_DIR \494 "$sigil" appliance config set \495 --config "$config_path" --runtime-dir "$runtime_directory" --json \496 <"$config_request" >"$config_set"497498transaction="$(python3 - "$config_set" <<'PY'499import json500import pathlib501import sys502result = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))503if result.get("changed") is not True or result.get("restart_required") is not True:504 raise SystemExit("config set did not require candidate validation")505print(result["transaction"])506PY507)"508509XDG_RUNTIME_DIR="$runtime_directory" \510 "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \511 >"$temp_root/candidate.log" 2>&1512XDG_RUNTIME_DIR="$runtime_directory" \513 "$sigil" appliance status --config "$config_path" --json --schema-version 2 >"$candidate_status"514candidate_instance="$(python3 - "$candidate_status" "$config_set" <<'PY'515import json516import pathlib517import sys518status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))519staged = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))520runtime = status["runtime"]521if runtime.get("daemon") != "stopped" or runtime.get("reached_ready") is not True:522 raise SystemExit("candidate did not stop cleanly after reaching ready")523if runtime.get("loaded_config_revision") != staged.get("candidate_revision"):524 raise SystemExit("candidate runtime revision does not match staged config")525if status.get("config", {}).get("pending_transaction", {}).get("transaction") != staged.get("transaction"):526 raise SystemExit("status omitted the pending config transaction")527print(runtime["instance_id"])528PY529)"530mkdir -m 0700 "$alternate_runtime_directory/sigil-spark"531cp "$runtime_directory/sigil-spark/daemon-status-v1.json" \532 "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"533chmod 0600 "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"534cp "$config_path" "$temp_root/pre-mismatch-config.toml"535cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"536cp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"537cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"538if env -u XDG_RUNTIME_DIR \539 "$sigil" appliance config commit \540 --config "$config_path" \541 --runtime-dir "$alternate_runtime_directory" \542 --transaction "$transaction" \543 --expected-instance "$candidate_instance" \544 --json >"$temp_root/mismatched-runtime-commit.out" \545 2>"$temp_root/mismatched-runtime-commit.error"; then546 printf 'config commit accepted candidate evidence from a different runtime namespace\n' >&2547 exit 1548fi549python3 - "$temp_root/mismatched-runtime-commit.error" <<'PY'550import json551import pathlib552import sys553error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))554if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:555 raise SystemExit(f"unexpected mismatched runtime error: {error}")556PY557cmp "$config_path" "$temp_root/pre-mismatch-config.toml"558cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"559cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"560cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"561env -u XDG_RUNTIME_DIR \562 "$sigil" appliance config commit \563 --config "$config_path" \564 --runtime-dir "$runtime_directory" \565 --transaction "$transaction" \566 --expected-instance "$candidate_instance" \567 --json >"$temp_root/config-commit.json"568XDG_RUNTIME_DIR="$runtime_directory" \569 "$sigil" appliance config show --config "$config_path" --json \570 >"$temp_root/committed-show.json"571572python3 - "$temp_root/committed-show.json" "$temp_root/rollback-request.json" <<'PY'573import json574import pathlib575import sys576show = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))577if show.get("pending_transaction") is not None or show["settings"]["framerate"] != 72:578 raise SystemExit("committed config state is incorrect")579request = {580 "schema_version": 1,581 "expected_revision": show["revision"],582 "settings": {583 "resolution": {"mode": "fixed", "width": 1280, "height": 800},584 "framerate": 60,585 "rate_control": None,586 },587}588pathlib.Path(sys.argv[2]).write_text(json.dumps(request), encoding="utf-8")589PY590committed_hash="$(python3 - "$config_path" <<'PY'591import hashlib592import pathlib593import sys594print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())595PY596)"597XDG_RUNTIME_DIR="$runtime_directory" \598 "$sigil" appliance config set --config "$config_path" --json \599 <"$temp_root/rollback-request.json" >"$temp_root/rollback-set.json"600rollback_transaction="$(python3 - "$temp_root/rollback-set.json" <<'PY'601import json602import pathlib603import sys604print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["transaction"])605PY606)"607python3 - "$state_directory/config-transaction-v1.json" <<'PY'608import json609import pathlib610import sys611path = pathlib.Path(sys.argv[1])612journal = json.loads(path.read_text(encoding="utf-8"))613journal.pop("runtime_directory_id", None)614path.write_text(json.dumps(journal) + "\n", encoding="utf-8")615PY616cp "$config_path" "$temp_root/pre-legacy-commit-config.toml"617cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"618cp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"619cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"620if env -u XDG_RUNTIME_DIR \621 "$sigil" appliance config commit \622 --config "$config_path" \623 --runtime-dir "$runtime_directory" \624 --transaction "$rollback_transaction" \625 --expected-instance "$candidate_instance" \626 --json >"$temp_root/legacy-commit.out" \627 2>"$temp_root/legacy-commit.error"; then628 printf 'config commit accepted a legacy transaction without runtime binding\n' >&2629 exit 1630fi631python3 - "$temp_root/legacy-commit.error" <<'PY'632import json633import pathlib634import sys635error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))636if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:637 raise SystemExit(f"unexpected legacy transaction commit error: {error}")638PY639cmp "$config_path" "$temp_root/pre-legacy-commit-config.toml"640cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"641cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"642cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"643env -u XDG_RUNTIME_DIR \644 "$sigil" appliance config rollback \645 --config "$config_path" --transaction "$rollback_transaction" --json \646 >"$temp_root/config-rollback.json"647[[ "$committed_hash" == "$(python3 - "$config_path" <<'PY'648import hashlib649import pathlib650import sys651print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())652PY653)" ]]654[[ ! -e "$state_directory/config-transaction-v1.json" ]]655[[ ! -e "$state_directory/config-base-v1.toml" ]]656[[ ! -e "$state_directory/config-candidate-v1.toml" ]]657658if env -u XDG_RUNTIME_DIR \659 "$sigil" serve --config "$config_path" --max-runtime-seconds 1 \660 >"$temp_root/missing-runtime.log" 2>&1; then661 printf 'configured Sigil unexpectedly started without XDG_RUNTIME_DIR\n' >&2662 exit 1663fi664grep -Fq 'configured Sigil service requires a valid XDG_RUNTIME_DIR' \665 "$temp_root/missing-runtime.log"666667printf 'sigil appliance status integration proof passed\n'