fix(appliance): support offline management without logind runtime
- commit
- 06e2c3b40c542efabbbc77bf63c7050f2ed6dc51
- parent
- c917330cfcac5012ce9478a1ff2d04f56f023756
- parent
- 1979ff8a8b3bf0dd1f949fe52adb4756e63f498a
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | crates/ | 74 | +++++++++++++-- |
| M | crates/ | 115 | ++++++++++++++++++++++- |
| M | crates/ | 24 | ++++- |
| M | docs/ | 6 | + |
| M | docs/ | 21 | ++++- |
| M | docs/ | 13 | ++- |
| M | docs/ | 7 | + |
| M | scripts/ | 147 | +++++++++++++++++++++++++++++- |
@@ -470,6 +470,27 @@ fn configured_runtime_directory() -> Result<Option<PathBuf>> {
470470 Ok(Some(root.join("sigil-spark")))471471 }472472 473+pub(crate) fn resolve_management_runtime_directory(474+ explicit_root: Option<&Path>,475+) -> Result<PathBuf> {476+ if let Some(root) = explicit_root {477+ ensure!(root.is_absolute(), "--runtime-dir must be absolute");478+ secure_state::validate_private_directory(root)479+ .context("validating --runtime-dir for Sigil appliance management")?;480+ 481+ {482+ use std::os::unix::fs::PermissionsExt as _;483+ ensure!(484+ fs::symlink_metadata(root)?.permissions().mode() & 0o7777 == 0o700,485+ "--runtime-dir must have mode 0700"486+ );487+ }488+ return Ok(root.join("sigil-spark"));489+ }490+ configured_runtime_directory()?491+ .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")492+}493+473494 pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV2> {474495 let loaded = HostConfig::load_document(config_path)?;475496 let config = &loaded.config;@@ -522,7 +543,7 @@ pub fn reset_enrollment(
522543 ) -> Result<EnrollmentResetV1> {523544 let config = HostConfig::load(config_path)?;524545 config.ensure_runtime_directory()?;525- let _lifecycle = LifecycleGuard::acquire(&config.state_path, true)?;546+ let _lifecycle = LifecycleGuard::acquire(&config.state_path, false)?;526547 let secret = crate::identity::load(&config.identity_path)?;527548 let host = secret.public();528549 let host_fingerprint = fingerprint(host);@@ -718,15 +739,16 @@ fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatus
718739 }719740 }720741 721-pub(crate) fn latest_runtime_instance(expected_host: EndpointId) -> Result<Option<String>> {722- let state_directory = configured_runtime_directory()?723- .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")?;724- match fs::symlink_metadata(&state_directory) {742+pub(crate) fn latest_runtime_instance(743+ state_directory: &Path,744+ expected_host: EndpointId,745+) -> Result<Option<String>> {746+ match fs::symlink_metadata(state_directory) {725747 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),726748 Err(error) => return Err(error).context("inspecting Sigil runtime state directory"),727749 Ok(_) => {}728750 }729- let Some(persisted) = read_persisted_runtime(&state_directory)? else {751+ let Some(persisted) = read_persisted_runtime(state_directory)? else {730752 return Ok(None);731753 };732754 ensure!(@@ -796,14 +818,13 @@ fn unavailable_runtime() -> RuntimeStatus {
796818 }797819 798820 pub(crate) fn validate_candidate_runtime(821+ state_directory: &Path,799822 expected_host: EndpointId,800823 expected_revision: &ConfigRevision,801824 expected_instance: &str,802825 ) -> Result<()> {803- let state_directory = configured_runtime_directory()?804- .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")?;805826 validate_candidate_runtime_at(806- &state_directory,827+ state_directory,807828 expected_host,808829 expected_revision,809830 expected_instance,@@ -984,6 +1005,41 @@ mod tests {
9841005 LifecycleGuard::acquire_at(second_state.path(), Some(runtime.path())).unwrap();9851006 }9861007 1008+ 1009+ fn lifecycle_lock_without_runtime_remains_state_scoped() {1010+ let first_state = private_directory();1011+ let second_state = private_directory();1012+ let first = LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1013+ assert!(LifecycleGuard::acquire_at(first_state.path(), None).is_err());1014+ LifecycleGuard::acquire_at(second_state.path(), None).unwrap();1015+ drop(first);1016+ LifecycleGuard::acquire_at(first_state.path(), None).unwrap();1017+ }1018+1019+ 1020+ 1021+ fn explicit_management_runtime_root_is_strict_and_appends_one_child() {1022+ use std::os::unix::fs::{PermissionsExt as _, symlink};1023+1024+ let root = private_directory();1025+ assert_eq!(1026+ resolve_management_runtime_directory(Some(root.path())).unwrap(),1027+ root.path().join("sigil-spark")1028+ );1029+ assert!(resolve_management_runtime_directory(Some(Path::new("relative"))).is_err());1030+1031+ fs::set_permissions(root.path(), fs::Permissions::from_mode(0o750)).unwrap();1032+ assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1033+ fs::set_permissions(root.path(), fs::Permissions::from_mode(0o1700)).unwrap();1034+ assert!(resolve_management_runtime_directory(Some(root.path())).is_err());1035+ fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();1036+1037+ let link_parent = private_directory();1038+ let link = link_parent.path().join("runtime-link");1039+ symlink(root.path(), &link).unwrap();1040+ assert!(resolve_management_runtime_directory(Some(&link)).is_err());1041+ }1042+9871043 9881044 fn missing_runtime_is_offline_without_creating_state() {9891045 let directory = private_directory();@@ -194,6 +194,8 @@ struct TransactionJournalV1 {
194194 base_revision: ConfigRevision,195195 candidate_revision: ConfigRevision,196196 baseline_runtime_instance: Option<String>,197+ 198+ runtime_directory_id: Option<ConfigRevision>,197199 }198200 199201 enum RecoveryCompletion {@@ -260,14 +262,21 @@ pub fn validate(
260262 })261263 }262264 263-pub fn set(config_path: &Path, request: &ConfigRequestV1) -> ManagementResult<ConfigSetV1> {265+pub fn set(266+ config_path: &Path,267+ request: &ConfigRequestV1,268+ runtime_root: Option<&Path>,269+) -> ManagementResult<ConfigSetV1> {264270 let bootstrap = load_config(config_path)?;265271 let state_directory = bootstrap.config.state_path.clone();272+ let runtime_directory = appliance::resolve_management_runtime_directory(runtime_root)273+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;266274 bootstrap267275 .config268276 .ensure_runtime_directory()269277 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;270- let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;278+ let (_lifecycle, _config_lock) =279+ acquire_management_locks_at(&state_directory, Some(&runtime_directory))?;271280 272281 let mut loaded = load_config(config_path)?;273282 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;@@ -297,8 +306,9 @@ pub fn set(config_path: &Path, request: &ConfigRequestV1) -> ManagementResult<Co
297306 let host = identity::load(&loaded.config.identity_path)298307 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?299308 .public();300- let baseline_runtime_instance = appliance::latest_runtime_instance(host)309+ let baseline_runtime_instance = appliance::latest_runtime_instance(&runtime_directory, host)301310 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;311+ let runtime_directory_id = runtime_directory_id(&runtime_directory)?;302312 303313 write_artifact(&state_directory, BASE_FILE, &loaded.bytes)?;304314 write_artifact(&state_directory, CANDIDATE_FILE, &candidate.bytes)?;@@ -310,6 +320,7 @@ pub fn set(config_path: &Path, request: &ConfigRequestV1) -> ManagementResult<Co
310320 base_revision: loaded.revision.clone(),311321 candidate_revision: candidate_revision.clone(),312322 baseline_runtime_instance,323+ runtime_directory_id: Some(runtime_directory_id),313324 };314325 write_journal(&state_directory, &journal)?;315326 replace_config(config_path, &candidate.bytes)?;@@ -336,12 +347,33 @@ pub fn commit(
336347 config_path: &Path,337348 transaction: &str,338349 expected_instance: &str,350+ runtime_root: Option<&Path>,339351 ) -> ManagementResult<ConfigCommitV1> {340352 validate_transaction_id(transaction)?;341353 validate_transaction_id(expected_instance)?;342354 let bootstrap = load_config(config_path)?;343355 let state_directory = bootstrap.config.state_path.clone();344- let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;356+ let runtime_directory = appliance::resolve_management_runtime_directory(runtime_root)357+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;358+ let (_lifecycle, _config_lock) =359+ acquire_management_locks_at(&state_directory, Some(&runtime_directory))?;360+ let observed_journal = read_journal(&state_directory)?.ok_or_else(|| {361+ ManagementError::new(362+ ManagementErrorCode::TransactionNotFound,363+ anyhow::anyhow!("no configuration transaction is pending"),364+ )365+ })?;366+ validate_journal(&observed_journal, config_path)?;367+ require_transaction(&observed_journal, transaction)?;368+ let runtime_directory_id = runtime_directory_id(&runtime_directory)?;369+ if observed_journal.runtime_directory_id.as_ref() != Some(&runtime_directory_id) {370+ return Err(ManagementError::new(371+ ManagementErrorCode::HealthNotProven,372+ anyhow::anyhow!(373+ "candidate validation runtime directory does not match the transaction"374+ ),375+ ));376+ }345377 let mut loaded = load_config(config_path)?;346378 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;347379 let recovered = recover(config_path, &mut loaded)?;@@ -375,6 +407,7 @@ pub fn commit(
375407 let secret = identity::load(&loaded.config.identity_path)376408 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;377409 appliance::validate_candidate_runtime(410+ &runtime_directory,378411 secret.public(),379412 &journal.candidate_revision,380413 expected_instance,@@ -396,7 +429,7 @@ pub fn rollback(config_path: &Path, transaction: &str) -> ManagementResult<Confi
396429 validate_transaction_id(transaction)?;397430 let bootstrap = load_config(config_path)?;398431 let state_directory = bootstrap.config.state_path.clone();399- let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory)?;432+ let (_lifecycle, _config_lock) = acquire_management_locks(&state_directory, false)?;400433 let mut loaded = load_config(config_path)?;401434 ensure_bootstrap_paths_unchanged(&bootstrap, &loaded)?;402435 let recovered = recover(config_path, &mut loaded)?;@@ -593,11 +626,30 @@ fn finish_service_prepare(
593626 594627 fn acquire_management_locks(595628 state_directory: &Path,629+ require_global: bool,596630 ) -> ManagementResult<(LifecycleGuard, fs::File)> {597- let lifecycle = match LifecycleGuard::try_acquire(state_directory, true) {631+ let lifecycle = match LifecycleGuard::try_acquire(state_directory, require_global) {598632 Ok(lifecycle) => lifecycle,599633 Err(error) => return Err(classify_lifecycle_error(state_directory, error)),600634 };635+ finish_acquire_management_locks(state_directory, lifecycle)636+}637+638+fn acquire_management_locks_at(639+ state_directory: &Path,640+ runtime_directory: Option<&Path>,641+) -> ManagementResult<(LifecycleGuard, fs::File)> {642+ let lifecycle = match LifecycleGuard::try_acquire_at(state_directory, runtime_directory) {643+ Ok(lifecycle) => lifecycle,644+ Err(error) => return Err(classify_lifecycle_error(state_directory, error)),645+ };646+ finish_acquire_management_locks(state_directory, lifecycle)647+}648+649+fn finish_acquire_management_locks(650+ state_directory: &Path,651+ lifecycle: LifecycleGuard,652+) -> ManagementResult<(LifecycleGuard, fs::File)> {601653 let config_lock = classify_config_lock(secure_state::try_open_lifetime_lock(602654 state_directory,603655 CONFIG_LOCK_FILE,@@ -852,17 +904,29 @@ fn validate_journal(journal: &TransactionJournalV1, config_path: &Path) -> Manag
852904 if let Some(instance) = &journal.baseline_runtime_instance {853905 validate_stored_id(instance)?;854906 }907+ if let Some(runtime_directory_id) = &journal.runtime_directory_id {908+ ConfigRevision::parse(runtime_directory_id.as_str())909+ .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;910+ }855911 Ok(())856912 }857913 858914 fn config_path_id(path: &Path) -> ManagementResult<ConfigRevision> {915+ canonical_path_id(path, "configuration")916+}917+918+fn runtime_directory_id(path: &Path) -> ManagementResult<ConfigRevision> {919+ canonical_path_id(path, "runtime directory")920+}921+922+fn canonical_path_id(path: &Path, description: &str) -> ManagementResult<ConfigRevision> {859923 let canonical = path860924 .canonicalize()861925 .map_err(|error| ManagementError::new(ManagementErrorCode::UnsafeStorage, error))?;862926 let value = canonical.to_str().ok_or_else(|| {863927 ManagementError::new(864928 ManagementErrorCode::UnsafeStorage,865- anyhow::anyhow!("configuration path is not valid UTF-8"),929+ anyhow::anyhow!("{description} path is not valid UTF-8"),866930 )867931 })?;868932 Ok(ConfigRevision::from_bytes(value.as_bytes()))@@ -990,6 +1054,7 @@ mod tests {
9901054 base_revision: self.base.revision.clone(),9911055 candidate_revision: candidate.revision.clone(),9921056 baseline_runtime_instance: None,1057+ runtime_directory_id: None,9931058 };9941059 write_journal(&self.state_directory, &journal).unwrap();9951060 (journal, candidate)@@ -1042,6 +1107,23 @@ mod tests {
10421107 assert_eq!(candidate.config.state_path, fixture.base.config.state_path);10431108 }10441109 1110+ 1111+ fn invalid_explicit_runtime_root_fails_before_config_mutation() {1112+ let fixture = Fixture::new();1113+ let original = fs::read(&fixture.config_path).unwrap();1114+ let error = set(1115+ &fixture.config_path,1116+ &fixture.request(72),1117+ Some(Path::new("relative-runtime")),1118+ )1119+ .unwrap_err();1120+ assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);1121+ assert_eq!(fs::read(&fixture.config_path).unwrap(), original);1122+ assert!(read_journal(&fixture.state_directory).unwrap().is_none());1123+ assert!(!fixture.state_directory.join(BASE_FILE).exists());1124+ assert!(!fixture.state_directory.join(CANDIDATE_FILE).exists());1125+ }1126+10451127 10461128 fn prepared_recovery_forward_applies_the_exact_candidate() {10471129 let fixture = Fixture::new();@@ -1150,6 +1232,25 @@ mod tests {
11501232 assert_eq!(error.code, ManagementErrorCode::UnsafeStorage);11511233 }11521234 1235+ 1236+ fn malformed_runtime_directory_binding_is_unsafe_storage() {1237+ let fixture = Fixture::new();1238+ let (journal, _candidate) = fixture.prepared(TransactionState::PendingValidation);1239+ let mut value = serde_json::to_value(journal).unwrap();1240+ value["runtime_directory_id"] = serde_json::json!("not-a-revision");1241+ secure_state::atomic_write(1242+ &fixture.state_directory,1243+ JOURNAL_FILE,1244+ &serde_json::to_vec(&value).unwrap(),1245+ MAX_JOURNAL_BYTES,1246+ )1247+ .unwrap();1248+ assert_eq!(1249+ read_journal(&fixture.state_directory).unwrap_err().code,1250+ ManagementErrorCode::UnsafeStorage1251+ );1252+ }1253+11531254 11541255 fn lock_failures_do_not_probe_or_invert_lock_order() {11551256 let fixture = Fixture::new();@@ -143,6 +143,9 @@ enum ApplianceConfigCommand {
143143 Set {144144 145145 config: PathBuf,146+ /// XDG runtime root used by the stopped service; Sigil appends `sigil-spark`.147+ 148+ runtime_dir: Option<PathBuf>,146149 147150 json: bool,148151 },@@ -154,6 +157,9 @@ enum ApplianceConfigCommand {
154157 transaction: String,155158 156159 expected_instance: String,160+ /// XDG runtime root used by the candidate service; Sigil appends `sigil-spark`.161+ 162+ runtime_dir: Option<PathBuf>,157163 158164 json: bool,159165 },@@ -387,11 +393,15 @@ fn appliance_command(command: ApplianceCommand) -> CliResult<()> {
387393 .map_err(CliFailure::Management)?,388394 )389395 }390- ApplianceConfigCommand::Set { config, json: _ } => {396+ ApplianceConfigCommand::Set {397+ config,398+ runtime_dir,399+ json: _,400+ } => {391401 let request = config_management::read_request(std::io::stdin().lock())392402 .map_err(CliFailure::Management)?;393403 serde_json::to_value(394- config_management::set(&config, &request)404+ config_management::set(&config, &request, runtime_dir.as_deref())395405 .map_err(CliFailure::Management)?,396406 )397407 }@@ -399,10 +409,16 @@ fn appliance_command(command: ApplianceCommand) -> CliResult<()> {
399409 config,400410 transaction,401411 expected_instance,412+ runtime_dir,402413 json: _,403414 } => serde_json::to_value(404- config_management::commit(&config, &transaction, &expected_instance)405- .map_err(CliFailure::Management)?,415+ config_management::commit(416+ &config,417+ &transaction,418+ &expected_instance,419+ runtime_dir.as_deref(),420+ )421+ .map_err(CliFailure::Management)?,406422 ),407423 ApplianceConfigCommand::Rollback {408424 config,@@ -105,6 +105,12 @@ sigil appliance enrollment-reset \
105105 systemctl --user start sigil-host.service106106 ```107107 108+Enrollment reset needs only the durable per-state lifecycle lock when109+`XDG_RUNTIME_DIR` is unavailable, so the stopped-host command also works from110+an SSH or recovery shell. If a valid XDG runtime root is present, reset also111+takes the per-user global lock. An unsafe or malformed XDG runtime directory is112+still rejected rather than ignored.113+108114 Then disconnect Portal and use **client -> reset enrollment** before importing109115 the replacement invitation. Portal never silently overwrites a redeemed host110116 profile.@@ -17,6 +17,24 @@ sigil appliance config rollback --config ~/.config/sigil-spark/host.toml \
1717 --transaction <id> --json1818 ```1919 20+`set` and `commit` use the service's XDG runtime root to share its global21+lifecycle lock and to bind baseline and candidate evidence to one exact runtime22+directory. When `XDG_RUNTIME_DIR` is unavailable (for example, from a recovery23+shell), pass the same owner-only mode-`0700` root to both commands with24+`--runtime-dir /run/user/<uid>`; Sigil appends `sigil-spark`. An explicit root25+takes precedence over the environment and must be absolute, owned by the26+effective user, and not a symlink. Without either source, `set` and `commit`27+fail closed. `rollback` needs no runtime evidence and can use only the durable28+state lock when XDG runtime state is unavailable.29+30+The transaction journal binds `set` to the canonical runtime child with a31+non-secret SHA-256 path identifier. `commit` must resolve to that same child32+before transaction recovery or mutation, so copying otherwise-valid stopped33+status evidence into a different runtime namespace cannot authorize commit.34+Legacy journals without this identifier remain rollback-capable but cannot be35+committed. Do not downgrade Sigil while a configuration transaction is pending;36+older binaries may not understand journals written by this version.37+2038 `show` returns the exact-byte SHA-256 revision, editable settings, and a pending2139 transaction summary. The editable v1 settings are:2240 @@ -55,7 +73,8 @@ Only a stopped Sigil can be changed. The controller performs:
5573 5674 1. Stop `sigil-host.service` and wait until it is inactive.5775 2. Call `set`. Sigil atomically installs the candidate and returns a transaction58- ID plus candidate revision.76+ ID plus candidate revision. If needed, pass the service's runtime root with77+ `--runtime-dir` and retain that exact value for `commit`.5978 3. Start the service and wait for appliance status to report a new instance in6079 `ready` state with the candidate revision.6180 4. Stop the service cleanly and retain that exact instance ID.@@ -12,10 +12,15 @@ sigil appliance enrollment-reset \
1212 ```1313 1414 The caller must first stop `sigil-host.service` and wait for it to become15-inactive. The command acquires both daemon-lifetime locks without waiting and16-fails before changing authorization state if a daemon still owns either lock.17-It never stops or starts systemd itself. This keeps service policy in the Decky18-backend and prevents a reset from leaving a live remote session attached.15+inactive. The command always acquires the durable16+`<state_path>/daemon-v1.lock` without waiting. When a valid private17+`XDG_RUNTIME_DIR` is available, it also acquires18+`$XDG_RUNTIME_DIR/sigil-spark/daemon-global-v1.lock`; an unsafe configured19+runtime directory fails closed. If the variable is unset, reset remains20+available from SSH or a recovery shell using only the per-state lock. A live21+daemon for that state still blocks the operation. The command never stops or22+starts systemd itself. This keeps service policy in the Decky backend and23+prevents a reset from leaving a live remote session attached.1924 2025 The expected fingerprint is the redacted value returned by2126 `sigil appliance status`. It is an accidental-target confirmation interlock,@@ -49,6 +49,13 @@ state directories. Configured production service mode requires a valid private
4949 Only the explicit direct test-pattern proof may fall back to its per-state lock5050 without publishing a heartbeat.5151 52+Offline enrollment reset and configuration rollback may fall back to the53+durable per-state lock when `XDG_RUNTIME_DIR` is unset. Configuration `set` and54+`commit` still require the service's exact runtime authority; management shells55+without the environment may supply its private XDG root explicitly with56+`--runtime-dir` (Sigil appends `sigil-spark`). Configured service startup itself57+continues to require `XDG_RUNTIME_DIR`.58+5259 Sigil atomically publishes5360 `$XDG_RUNTIME_DIR/sigil-spark/daemon-status-v1.json` at most once per two-second5461 heartbeat, plus explicit lifecycle transitions. Keeping the heartbeat in the@@ -38,6 +38,8 @@ trap cleanup EXIT INT TERM HUP
3838 identity_directory="$temp_root/identity"3939 state_directory="$temp_root/state"4040 runtime_directory="$temp_root/runtime"41+alternate_runtime_directory="$temp_root/runtime-alternate"42+invalid_runtime_directory="$temp_root/invalid-runtime"4143 identity_path="$identity_directory/host.key"4244 config_path="$temp_root/host.toml"4345 host_log="$temp_root/host.log"@@ -51,7 +53,12 @@ config_request="$temp_root/config-request.json"
5153 config_set="$temp_root/config-set.json"5254 candidate_status="$temp_root/candidate-status.json"5355 54-mkdir -m 0700 "$identity_directory" "$state_directory" "$runtime_directory"56+mkdir -m 0700 \57+ "$identity_directory" \58+ "$state_directory" \59+ "$runtime_directory" \60+ "$alternate_runtime_directory"61+mkdir -m 0755 "$invalid_runtime_directory"5562 "$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"5663 host_node_id="$(sed -n 's/^node_id=//p' "$temp_root/identity.log")"5764 [[ -n "$host_node_id" ]] || {@@ -201,7 +208,7 @@ print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["identit
201208 PY202209 )"203210 204-if XDG_RUNTIME_DIR="$runtime_directory" \211+if env -u XDG_RUNTIME_DIR \205212 "$sigil" appliance enrollment-reset \206213 --config "$config_path" \207214 --expected-host-fingerprint "$host_fingerprint" \@@ -226,7 +233,17 @@ if XDG_RUNTIME_DIR="$runtime_directory" \
226233 exit 1227234 fi228235 [[ ! -e "$state_directory/authorization-v1.json" ]]229-XDG_RUNTIME_DIR="$runtime_directory" \236+if XDG_RUNTIME_DIR="$invalid_runtime_directory" \237+ "$sigil" appliance enrollment-reset \238+ --config "$config_path" \239+ --expected-host-fingerprint "$host_fingerprint" \240+ --json \241+ >"$temp_root/invalid-runtime-reset.log" 2>&1; then242+ printf 'enrollment reset ignored an unsafe XDG runtime root\n' >&2243+ exit 1244+fi245+[[ ! -e "$state_directory/authorization-v1.json" ]]246+env -u XDG_RUNTIME_DIR \230247 "$sigil" appliance enrollment-reset \231248 --config "$config_path" \232249 --expected-host-fingerprint "$host_fingerprint" \@@ -307,8 +324,58 @@ import sys
307324 print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())308325 PY309326 )" ]]310-XDG_RUNTIME_DIR="$runtime_directory" \327+if env -u XDG_RUNTIME_DIR \328+ "$sigil" appliance config set \329+ --config "$config_path" --runtime-dir "$invalid_runtime_directory" --json \330+ <"$config_request" >"$temp_root/invalid-runtime-set.out" \331+ 2>"$temp_root/invalid-runtime-set.error"; then332+ printf 'config set accepted an unsafe explicit runtime root\n' >&2333+ exit 1334+fi335+python3 - "$temp_root/invalid-runtime-set.error" <<'PY'336+import json337+import pathlib338+import sys339+error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))340+if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:341+ raise SystemExit(f"unexpected unsafe runtime-root error: {error}")342+PY343+[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'344+import hashlib345+import pathlib346+import sys347+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())348+PY349+)" ]]350+[[ ! -e "$state_directory/config-transaction-v1.json" ]]351+352+if env -u XDG_RUNTIME_DIR \311353 "$sigil" appliance config set --config "$config_path" --json \354+ <"$config_request" >"$temp_root/missing-runtime-set.out" \355+ 2>"$temp_root/missing-runtime-set.error"; then356+ printf 'config set unexpectedly accepted no runtime authority\n' >&2357+ exit 1358+fi359+python3 - "$temp_root/missing-runtime-set.error" <<'PY'360+import json361+import pathlib362+import sys363+error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))364+if error != {"schema_version": 1, "error": {"code": "unsafe_storage"}}:365+ raise SystemExit(f"unexpected missing runtime-root error: {error}")366+PY367+[[ "$base_hash" == "$(python3 - "$config_path" <<'PY'368+import hashlib369+import pathlib370+import sys371+print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())372+PY373+)" ]]374+[[ ! -e "$state_directory/config-transaction-v1.json" ]]375+376+env -u XDG_RUNTIME_DIR \377+ "$sigil" appliance config set \378+ --config "$config_path" --runtime-dir "$runtime_directory" --json \312379 <"$config_request" >"$config_set"313380 314381 transaction="$(python3 - "$config_set" <<'PY'@@ -343,9 +410,41 @@ if status.get("config", {}).get("pending_transaction", {}).get("transaction") !=
343410 print(runtime["instance_id"])344411 PY345412 )"346-XDG_RUNTIME_DIR="$runtime_directory" \413+mkdir -m 0700 "$alternate_runtime_directory/sigil-spark"414+cp "$runtime_directory/sigil-spark/daemon-status-v1.json" \415+ "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"416+chmod 0600 "$alternate_runtime_directory/sigil-spark/daemon-status-v1.json"417+cp "$config_path" "$temp_root/pre-mismatch-config.toml"418+cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"419+cp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"420+cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"421+if env -u XDG_RUNTIME_DIR \347422 "$sigil" appliance config commit \348423 --config "$config_path" \424+ --runtime-dir "$alternate_runtime_directory" \425+ --transaction "$transaction" \426+ --expected-instance "$candidate_instance" \427+ --json >"$temp_root/mismatched-runtime-commit.out" \428+ 2>"$temp_root/mismatched-runtime-commit.error"; then429+ printf 'config commit accepted candidate evidence from a different runtime namespace\n' >&2430+ exit 1431+fi432+python3 - "$temp_root/mismatched-runtime-commit.error" <<'PY'433+import json434+import pathlib435+import sys436+error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))437+if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:438+ raise SystemExit(f"unexpected mismatched runtime error: {error}")439+PY440+cmp "$config_path" "$temp_root/pre-mismatch-config.toml"441+cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-mismatch-journal.json"442+cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-mismatch-base.toml"443+cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-mismatch-candidate.toml"444+env -u XDG_RUNTIME_DIR \445+ "$sigil" appliance config commit \446+ --config "$config_path" \447+ --runtime-dir "$runtime_directory" \349448 --transaction "$transaction" \350449 --expected-instance "$candidate_instance" \351450 --json >"$temp_root/config-commit.json"@@ -388,7 +487,43 @@ import sys
388487 print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["transaction"])389488 PY390489 )"391-XDG_RUNTIME_DIR="$runtime_directory" \490+python3 - "$state_directory/config-transaction-v1.json" <<'PY'491+import json492+import pathlib493+import sys494+path = pathlib.Path(sys.argv[1])495+journal = json.loads(path.read_text(encoding="utf-8"))496+journal.pop("runtime_directory_id", None)497+path.write_text(json.dumps(journal) + "\n", encoding="utf-8")498+PY499+cp "$config_path" "$temp_root/pre-legacy-commit-config.toml"500+cp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"501+cp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"502+cp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"503+if env -u XDG_RUNTIME_DIR \504+ "$sigil" appliance config commit \505+ --config "$config_path" \506+ --runtime-dir "$runtime_directory" \507+ --transaction "$rollback_transaction" \508+ --expected-instance "$candidate_instance" \509+ --json >"$temp_root/legacy-commit.out" \510+ 2>"$temp_root/legacy-commit.error"; then511+ printf 'config commit accepted a legacy transaction without runtime binding\n' >&2512+ exit 1513+fi514+python3 - "$temp_root/legacy-commit.error" <<'PY'515+import json516+import pathlib517+import sys518+error = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))519+if error != {"schema_version": 1, "error": {"code": "health_not_proven"}}:520+ raise SystemExit(f"unexpected legacy transaction commit error: {error}")521+PY522+cmp "$config_path" "$temp_root/pre-legacy-commit-config.toml"523+cmp "$state_directory/config-transaction-v1.json" "$temp_root/pre-legacy-commit-journal.json"524+cmp "$state_directory/config-base-v1.toml" "$temp_root/pre-legacy-commit-base.toml"525+cmp "$state_directory/config-candidate-v1.toml" "$temp_root/pre-legacy-commit-candidate.toml"526+env -u XDG_RUNTIME_DIR \392527 "$sigil" appliance config rollback \393528 --config "$config_path" --transaction "$rollback_transaction" --json \394529 >"$temp_root/config-rollback.json"