Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/webcodex-core/src/runner_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,10 @@ pub struct RunnerProjectSummary {
/// Stable SHA-256 revision of the persisted project registration record TOML content.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
/// Opaque stable ID for this exact persisted Project registration. A new
/// registration must never reuse the ID, even for the same root and name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registration_incarnation: Option<String>,
/// Domain-separated identity of the currently observed canonical Project
/// root. Missing means the Runner could not prove a current root identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
1 change: 1 addition & 0 deletions crates/webcodex-runner-registry/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fn agent_auth_context(

fn project_summary(id: &str, path: &str) -> RunnerProjectSummary {
RunnerProjectSummary {
registration_incarnation: None,
id: id.to_string(),
name: Some(id.to_string()),
path: path.to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ fn reconciliation_capabilities() -> RunnerCapabilities {

fn project_summary() -> RunnerProjectSummary {
RunnerProjectSummary {
registration_incarnation: None,
id: PROJECT_ID.to_string(),
name: Some("Demo".to_string()),
path: "/srv/demo".to_string(),
Expand Down
7 changes: 7 additions & 0 deletions crates/webcodex-runner-registry/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,13 @@ pub(super) fn validate_project_summary(project: &RunnerProjectSummary) -> Result
{
return Err("project_summary_invalid_root_fingerprint");
}
if project
.registration_incarnation
.as_deref()
.is_some_and(|value| uuid::Uuid::parse_str(value).is_err())
{
return Err("project_summary_invalid_registration_incarnation");
}
if let Some(RunnerProjectLineage::ManagedWorktreeSource {
source_project_id,
source_root_fingerprint,
Expand Down
197 changes: 197 additions & 0 deletions crates/webcodex-runner/src/main_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,7 @@ fn register_managed_source_project_as(registry: &Path, source: &Path, id: &str)
name: Some("Source".to_string()),
kind: Some("repo".to_string()),
registration_source: None,
registration_incarnation: None,
description: None,
disabled: false,
hooks: HashMap::new(),
Expand Down Expand Up @@ -2078,6 +2079,202 @@ fn runner_project_cache_invalidate_refreshes_after_project_op() {
assert_eq!(projects[0].id, "cached");
}

#[test]
fn runner_registration_incarnation_survives_reload_and_rotates_after_unregister() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
let registry = tmp.path().join("project-registry");
std::fs::create_dir(&root).unwrap();
let policy = project_policy(tmp.path());
let register = || {
project_request(
"register_project",
serde_json::json!({
"id": "reusable",
"name": "Reusable",
"path": root.to_string_lossy()
}),
)
};

let first = project_ok(handle_project_op(&policy, &registry, &register()));
let original_id = first["registration_incarnation"]
.as_str()
.expect("new registration must persist an incarnation")
.to_string();
uuid::Uuid::parse_str(&original_id).unwrap();

let persisted = std::fs::read_to_string(registry.join("reusable.toml")).unwrap();
let parsed = parse_runner_project_toml(&persisted).unwrap();
assert_eq!(
parsed.registration_incarnation.as_deref(),
Some(original_id.as_str())
);
assert_eq!(
runner_project_summary(&parsed, 1, false)
.registration_incarnation
.as_deref(),
Some(original_id.as_str())
);

let recovered = project_ok(handle_project_op(&policy, &registry, &register()));
assert_eq!(recovered["registration_incarnation"], original_id);
let removed = project_ok(handle_project_lifecycle_op(
&policy,
&registry,
&project_request(
"project_lifecycle_unregister",
serde_json::json!({
"project_id": "reusable",
"expected_revision": first["revision"],
}),
),
));
assert_eq!(removed["changed"], true);
assert!(!registry.join("reusable.toml").exists());

let replacement = project_ok(handle_project_op(&policy, &registry, &register()));
let replacement_id = replacement["registration_incarnation"]
.as_str()
.expect("new registration must have an incarnation");
assert_ne!(replacement_id, original_id);
uuid::Uuid::parse_str(replacement_id).unwrap();
}

#[test]
fn project_lifecycle_receipts_keep_the_persisted_registration_incarnation() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
let registry = tmp.path().join("project-registry");
std::fs::create_dir(&root).unwrap();
let policy = project_policy(tmp.path());

let registered = project_ok(handle_project_op(
&policy,
&registry,
&project_request(
"register_project",
serde_json::json!({
"id": "stable",
"name": "Stable",
"path": root.to_string_lossy()
}),
),
));
let incarnation = registered["registration_incarnation"]
.as_str()
.expect("new Project registration must have an identity")
.to_string();
let mut revision = registered["revision"].as_str().unwrap().to_string();

for (operation, outcome, disabled) in [
("project_lifecycle_enable", "already_enabled", false),
("project_lifecycle_disable", "disabled", true),
("project_lifecycle_disable", "already_disabled", true),
("project_lifecycle_enable", "enabled", false),
] {
let reply = project_ok(handle_project_lifecycle_op(
&policy,
&registry,
&project_request(
operation,
serde_json::json!({
"project_id": "stable",
"expected_revision": revision
}),
),
));
assert_eq!(reply["outcome"], outcome);
assert_eq!(reply["disabled"], disabled);
assert_eq!(
reply["registration_incarnation"], incarnation,
"{operation} must not drop the Project registration identity in its receipt"
);
revision = reply["revision"].as_str().unwrap().to_string();

let reloaded = load_runner_project_summaries_from_dir(&registry);
assert_eq!(reloaded.len(), 1);
assert_eq!(
reloaded[0].registration_incarnation.as_deref(),
Some(incarnation.as_str())
);
}
}

#[test]
fn legacy_project_toml_migration_is_durable_and_never_resumes_old_registration() {
let tmp = tempfile::tempdir().unwrap();
let project_dir = tmp.path().join("repo");
let registry = tmp.path().join("project-registry");
std::fs::create_dir(&project_dir).unwrap();
std::fs::create_dir(&registry).unwrap();
let record = registry.join("legacy.toml");
let legacy_toml = format!(
"id = \"legacy\"\npath = {:?}\nname = \"Legacy\"\n[hooks]\ncheck = [\"echo safe\"]\n",
project_dir.to_string_lossy()
);
std::fs::write(&record, &legacy_toml).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o600)).unwrap();
}

let first = load_runner_project_summaries_from_dir(&registry);
assert_eq!(first.len(), 1);
let first_identity = first[0].registration_incarnation.as_deref().unwrap();
uuid::Uuid::parse_str(first_identity).unwrap();

let upgraded_toml = std::fs::read_to_string(&record).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&record).unwrap().permissions().mode() & 0o777,
0o600,
"migration must retain the private TOML file mode",
);
}
assert!(
upgraded_toml.starts_with("registration_incarnation = "),
"migration must prepend the key before a trailing TOML table"
);
assert!(upgraded_toml.contains("[hooks]\ncheck = [\"echo safe\"]"));
let upgraded = parse_runner_project_toml(&upgraded_toml).unwrap();
assert_eq!(
upgraded.registration_incarnation.as_deref(),
Some(first_identity)
);
assert_eq!(
load_runner_project_summaries_from_dir(&registry)[0]
.registration_incarnation
.as_deref(),
Some(first_identity)
);

let removed = project_ok(handle_project_lifecycle_op(
&project_policy(tmp.path()),
&registry,
&project_request(
"project_lifecycle_unregister",
serde_json::json!({
"project_id": "legacy",
"expected_revision": first[0].revision
}),
),
));
assert_eq!(removed["changed"], true);

// Even a byte-for-byte reintroduced legacy config is a NEW registration.
std::fs::write(&record, legacy_toml).unwrap();
let replacement = load_runner_project_summaries_from_dir(&registry);
assert_eq!(replacement.len(), 1);
assert_ne!(
replacement[0].registration_incarnation,
first[0].registration_incarnation
);
}

#[test]
fn http_sink_client_id_matches_config() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
3 changes: 3 additions & 0 deletions crates/webcodex-runner/src/webcodex_runner/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub(crate) struct RunnerProjectFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) registration_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) registration_incarnation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) disabled: bool,
Expand Down Expand Up @@ -294,6 +296,7 @@ mod durability_tests {
name: None,
kind: None,
registration_source: None,
registration_incarnation: None,
description: None,
disabled: false,
hooks: HashMap::new(),
Expand Down
56 changes: 44 additions & 12 deletions crates/webcodex-runner/src/webcodex_runner/projects/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ pub(crate) fn parse_runner_project_toml(content: &str) -> Result<RunnerProjectFi
project.name = trim_optional(project.name);
project.kind = trim_optional(project.kind);
project.registration_source = trim_optional(project.registration_source);
project.registration_incarnation = trim_optional(project.registration_incarnation);
if project
.registration_incarnation
.as_deref()
.is_some_and(|id| uuid::Uuid::parse_str(id).is_err())
{
return Err("invalid project registration incarnation".to_string());
}
project.description = trim_optional(project.description);
project.managed_source = trim_optional(project.managed_source);
project.managed_source_project_id = trim_optional(project.managed_source_project_id);
Expand Down Expand Up @@ -595,6 +603,7 @@ fn runner_project_summary_with_shutdown(
hooks,
disabled: project.disabled,
revision: Some(project_revision(project)),
registration_incarnation: project.registration_incarnation.clone(),
root_fingerprint,
lineage: project_lineage(project),
git_branch,
Expand Down Expand Up @@ -629,6 +638,40 @@ fn warn_empty_hook_commands(source: &Path, project: &RunnerProjectFile) {
}
}

/// Upgrade a readable pre-incarnation TOML exactly once, before advertising it
/// to the Server. This preserves a stable registration across Runner restarts
/// without trusting an old path-only Session as live execution authority.
/// On any write failure the Project stays undiscoverable, rather than exposing
/// an ephemeral incarnation that would rotate on every refresh.
fn load_runner_project_with_incarnation(file: &Path) -> Result<RunnerProjectFile, String> {
let _guard = super::project_registry_write_lock()
.lock()
.map_err(|_| "project registry lock poisoned".to_string())?;
let content = std::fs::read_to_string(file).map_err(|error| format!("read failed: {error}"))?;
let project = parse_runner_project_toml(&content)?;
if project.registration_incarnation.is_some() {
return Ok(project);
}
if std::fs::symlink_metadata(file)
.map_err(|error| format!("metadata failed: {error}"))?
.file_type()
.is_symlink()
{
return Err("legacy Project symlink cannot be upgraded safely".to_string());
}
let incarnation = uuid::Uuid::new_v4();
// Prepend the top-level key so TOML records ending in [hooks] or another
// table retain their exact table scope and hand-authored contents.
let updated = format!("registration_incarnation = \"{incarnation}\"\n{content}");
let upgraded = parse_runner_project_toml(&updated)?;
super::lifecycle::write_existing_project_atomic(file, &updated)?;
eprintln!(
"webcodex-runner project: migrated legacy registration {}",
file.display()
);
Ok(upgraded)
}

fn load_runner_project_summaries_from_dir_with_shutdown(
dir: &Path,
shutdown: Option<&AtomicBool>,
Expand Down Expand Up @@ -661,18 +704,7 @@ fn load_runner_project_summaries_from_dir_with_shutdown(
if shutdown.is_some_and(|flag| flag.load(Ordering::SeqCst)) {
break;
}
let content = match std::fs::read_to_string(&file) {
Ok(content) => content,
Err(e) => {
eprintln!(
"webcodex-runner project warning: failed to read {}: {}",
file.display(),
e
);
continue;
}
};
let project = match parse_runner_project_toml(&content) {
let project = match load_runner_project_with_incarnation(&file) {
Ok(project) => project,
Err(e) => {
eprintln!(
Expand Down
Loading