From 0e0a978c0f6062c9eb50d3099fece36b0c8fdc1f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:33:52 +0200 Subject: [PATCH] fix(catalog): admit a prepared plane without runtime workspace dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.workspace` is runtime-only: the agent owns its contents, nothing declares them, and #425 made the raw preimage capture none of them. The apply side kept requiring every declared workspace fact to already exist in the prepared plane, so the exact repair path the raw preimage exists for became unreachable: a prepared plane built from a raw snapshot has no such directory, and `digest --prepared` failed with inspect prepared workspace fact /agents///.workspace Caused by: No such file or directory (os error 2) Admission now tolerates an absent directory and keeps every other rule: a directory that IS there must still be a real, empty one, so prepared state cannot smuggle runtime bytes through the declaration plane. The fact itself stays in the projection, so `materialize_projection` republishes it as an empty directory and the applied catalog still satisfies the live invariant that a declared workspace fact is present. The relaxation is uniform across prepared planes rather than raw-only: `catalog digest --prepared` has no raw mode, so the deployer's digest-then-apply sequence cannot express the distinction, and an absent empty directory carries no information the CAS does not already cover — the declared fact is hashed either way. Regression test covers the deployer's shape end to end: raw-snapshot an invalid live plane whose declared workspace is runtime-only, publish valid declaration bytes into that snapshot, and bind the apply to the opaque preimage. Without this fix it reproduces the error above verbatim. The existing workspace-fact test loses its "missing" case, which asserted the contract this fixes. Its remaining "nonempty" rejection is unchanged. agent-identity: dev3.direct.omp.quzh99p3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@9323c3a --- docs/vrs/spec.md | 6 +- src/catalog_transaction.rs | 33 ++++++-- tests/catalog_apply.rs | 161 ++++++++++++++++++++++++++++++------- 3 files changed, 162 insertions(+), 38 deletions(-) diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 0dda8d95..6a9238ad 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -413,8 +413,10 @@ and either reports `unchanged` for exact equality or creates a durable content-addressed stage before publishing the marker. Version 1 requires an explicit PTY root outside the canonical catalog. Hash-CAS permits declared live workspace facts and their real ancestry to contain content. It changes -declaration leaves only; desired workspace facts must already exist, and -workspace content and canonical state are never traversed, deleted, or hashed. +declaration leaves only; a desired workspace fact is runtime-only, so the +prepared plane need not carry its directory — a raw preimage captures none of +them — and the transaction publishes any missing one as an empty directory. +Workspace content and canonical state are never traversed, deleted, or hashed. When an identity path is absent, its complete bundle uses an exclusive directory rename. When its declared workspace skeleton already exists, the durable marker fences declaration readers and marker-time state routing until diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 6357397c..d33ea901 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -2084,15 +2084,36 @@ fn catalog_workspace_dirs( Ok(facts) } +/// Admit the prepared plane's canonical workspace facts. +/// +/// `.workspace` is a runtime-only directory: the agent creates it, nothing declares its contents, +/// and a raw preimage deliberately captures none of them. A prepared plane built from such a +/// preimage therefore legitimately has no directory for a declared fact, and demanding one made +/// every repair transaction over a raw preimage unreachable. The fact stays in the projection +/// either way — `materialize_projection` publishes it as an empty directory — so the applied +/// catalog still satisfies the live invariant that a declared workspace fact is present. +/// +/// A directory that IS there still has to be a real, empty one: prepared state may not smuggle +/// runtime bytes through the declaration plane. fn validate_prepared_workspace_facts(root: &Path, facts: &BTreeSet) -> Result<()> { for relative in facts { let path = root.join(relative); - ensure_real_dir_chain_present(root, &path, "prepared workspace fact")?; - anyhow::ensure!( - sorted_entries(&path)?.is_empty(), - "prepared workspace fact must be an empty directory: {}", - path.display() - ); + match fs::symlink_metadata(&path) { + Ok(_) => { + ensure_real_dir_chain_present(root, &path, "prepared workspace fact")?; + anyhow::ensure!( + sorted_entries(&path)?.is_empty(), + "prepared workspace fact must be an empty directory: {}", + path.display() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("inspect prepared workspace fact {}", path.display()) + }); + } + } } Ok(()) } diff --git a/tests/catalog_apply.rs b/tests/catalog_apply.rs index cabc28bf..874bc908 100644 --- a/tests/catalog_apply.rs +++ b/tests/catalog_apply.rs @@ -1867,6 +1867,94 @@ fn raw_preimage_repairs_an_invalid_catalog_and_preserves_mutable_state() { ); } +#[test] +fn raw_preimage_repairs_a_catalog_whose_declared_workspace_fact_is_runtime_only() { + // The deployer's repair path: raw-snapshot the invalid live plane, publish valid declaration + // bytes into that snapshot, then bind the apply to the opaque preimage. A raw preimage + // captures no runtime directory, so the prepared plane it produces has none for a declared + // workspace fact — admission must not demand one. + let temp = tempfile::tempdir().unwrap(); + let catalog = temp.path().join("catalog"); + write_agent(&catalog, "worker", false); + ensure_external_pty_config(&catalog); + let dir = agent_dir(&catalog, "worker"); + fs::write( + dir.join("agent.kdl"), + "agent \"worker\" {\n host \"host\"\n desired-state \"running\" because=\"unsupported\"\n workspace \".workspace\"\n argv \"true\"\n}\n", + ) + .unwrap(); + let workspace = dir.join(".workspace"); + fs::create_dir_all(&workspace).unwrap(); + fs::write(workspace.join("session.txt"), "live runtime state").unwrap(); + + let strict_snapshot = st2() + .args([ + "catalog", + "snapshot", + "--catalog", + catalog.to_str().unwrap(), + "--output", + temp.path().join("strict-invalid").to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + !strict_snapshot.status.success(), + "invalid live plane unexpectedly passed the strict projection" + ); + + let prepared = temp.path().join("raw-capture"); + let raw_capture = raw_snapshot(&catalog, &prepared); + assert!( + raw_capture.status.success(), + "{}", + String::from_utf8_lossy(&raw_capture.stderr) + ); + let raw_capture: Value = serde_json::from_slice(&raw_capture.stdout).unwrap(); + assert_eq!( + raw_capture["schema"], + "st2.catalog-raw-preimage-snapshot.v1" + ); + assert!(!prepared.join("agents/host/worker/.workspace").exists()); + + fs::write( + prepared.join("agents/host/worker/agent.kdl"), + "agent \"worker\" {\n host \"host\"\n retired #false\n workspace \".workspace\"\n argv \"true\"\n}\n", + ) + .unwrap(); + + let repaired = raw_apply( + &catalog, + &prepared, + raw_capture["rootSha256"].as_str().unwrap(), + ); + assert!( + repaired.status.success(), + "{}", + String::from_utf8_lossy(&repaired.stderr) + ); + let repaired: Value = serde_json::from_slice(&repaired.stdout).unwrap(); + assert_eq!(repaired["schema"], "st2.catalog-raw-preimage-apply.v1"); + assert_eq!(repaired["status"], "applied"); + assert_eq!(repaired["beforeSha256"], raw_capture["rootSha256"]); + assert!( + !fs::read_to_string(dir.join("agent.kdl")) + .unwrap() + .contains("because=\"unsupported\"") + ); + assert!(workspace.is_dir()); + assert_eq!( + fs::read_to_string(workspace.join("session.txt")).unwrap(), + "live runtime state" + ); + assert!(!catalog.join(".st2/catalog-apply-incomplete").exists()); + + // The repaired plane is strictly projectable again, workspace fact included. + let strict = snapshot(&catalog, &temp.path().join("strict-repaired")); + assert!(strict["rootSha256"].as_str().unwrap().len() == 64); +} + #[test] fn raw_preimage_migrates_legacy_argv_profile_to_a_component_catalog() { let temp = tempfile::tempdir().unwrap(); @@ -2422,36 +2510,49 @@ fn workspace_facts_are_empty_in_prepared_admitted_against_live_and_never_applied ) .unwrap(); - for case in ["missing", "content"] { - let invalid = temp.path().join(format!("invalid-{case}")); - let current = snapshot(&catalog, &invalid); - let fact = invalid.join("agents/host/worker/.workspace"); - if case == "missing" { - fs::remove_dir(&fact).unwrap(); - } else { - let secret = temp.path().join("workspace-secret"); - fs::write(&secret, "must never be opened or copied").unwrap(); - std::os::unix::fs::symlink(&secret, fact.join("forbidden-link")).unwrap(); - } - let rejected = apply(&catalog, &invalid, current["rootSha256"].as_str().unwrap()); - assert!( - !rejected.status.success(), - "prepared workspace {case} unexpectedly succeeded" - ); - if case == "content" { - assert!( - String::from_utf8_lossy(&rejected.stderr) - .contains("prepared workspace fact must be empty"), - "{}", - String::from_utf8_lossy(&rejected.stderr) - ); - } - assert!(!catalog.join(".st2/catalog-apply-incomplete").exists()); - assert_eq!( - fs::read_to_string(workspace.join("live.txt")).unwrap(), - "preserve" - ); - } + // A declared workspace fact is runtime-only: the agent owns its contents, a raw preimage + // captures none of them, and the transaction republishes the fact as an empty directory. + // A prepared plane that carries no directory for it is therefore admitted, and the live + // contents survive. + let absent = temp.path().join("prepared-absent-fact"); + let current = snapshot(&catalog, &absent); + fs::remove_dir(absent.join("agents/host/worker/.workspace")).unwrap(); + let admitted = apply(&catalog, &absent, current["rootSha256"].as_str().unwrap()); + assert!( + admitted.status.success(), + "prepared plane without a declared workspace fact was rejected: {}", + String::from_utf8_lossy(&admitted.stderr) + ); + assert!(workspace.is_dir()); + assert!(task_workspace.is_dir()); + assert!(!catalog.join(".st2/catalog-apply-incomplete").exists()); + assert_eq!( + fs::read_to_string(workspace.join("live.txt")).unwrap(), + "preserve" + ); + + // Runtime bytes still may not ride into the declaration plane through the fact. + let invalid = temp.path().join("invalid-content"); + let current = snapshot(&catalog, &invalid); + let fact = invalid.join("agents/host/worker/.workspace"); + let secret = temp.path().join("workspace-secret"); + fs::write(&secret, "must never be opened or copied").unwrap(); + std::os::unix::fs::symlink(&secret, fact.join("forbidden-link")).unwrap(); + let rejected = apply(&catalog, &invalid, current["rootSha256"].as_str().unwrap()); + assert!( + !rejected.status.success(), + "prepared workspace content unexpectedly succeeded" + ); + assert!( + String::from_utf8_lossy(&rejected.stderr).contains("prepared workspace fact must be empty"), + "{}", + String::from_utf8_lossy(&rejected.stderr) + ); + assert!(!catalog.join(".st2/catalog-apply-incomplete").exists()); + assert_eq!( + fs::read_to_string(workspace.join("live.txt")).unwrap(), + "preserve" + ); let prepared = temp.path().join("prepared"); let before = snapshot(&catalog, &prepared);