diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 2844633fc..80b077749 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -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, + /// 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, /// 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")] diff --git a/crates/webcodex-runner-registry/src/tests.rs b/crates/webcodex-runner-registry/src/tests.rs index d63af8e9a..449057580 100644 --- a/crates/webcodex-runner-registry/src/tests.rs +++ b/crates/webcodex-runner-registry/src/tests.rs @@ -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(), diff --git a/crates/webcodex-runner-registry/src/tests/reconciliation.rs b/crates/webcodex-runner-registry/src/tests/reconciliation.rs index be267adf2..f7198002a 100644 --- a/crates/webcodex-runner-registry/src/tests/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/tests/reconciliation.rs @@ -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(), diff --git a/crates/webcodex-runner-registry/src/validation.rs b/crates/webcodex-runner-registry/src/validation.rs index 3001ef13f..9170a6d2b 100644 --- a/crates/webcodex-runner-registry/src/validation.rs +++ b/crates/webcodex-runner-registry/src/validation.rs @@ -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, diff --git a/crates/webcodex-runner/src/main_tests.rs b/crates/webcodex-runner/src/main_tests.rs index cf5d1e8fa..fe20cd837 100644 --- a/crates/webcodex-runner/src/main_tests.rs +++ b/crates/webcodex-runner/src/main_tests.rs @@ -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(), @@ -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, ®istry, ®ister())); + 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, ®istry, ®ister())); + assert_eq!(recovered["registration_incarnation"], original_id); + let removed = project_ok(handle_project_lifecycle_op( + &policy, + ®istry, + &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, ®istry, ®ister())); + 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, + ®istry, + &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, + ®istry, + &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(®istry); + 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(®istry).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(®istry); + 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(®istry)[0] + .registration_incarnation + .as_deref(), + Some(first_identity) + ); + + let removed = project_ok(handle_project_lifecycle_op( + &project_policy(tmp.path()), + ®istry, + &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(®istry); + 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(); diff --git a/crates/webcodex-runner/src/webcodex_runner/projects.rs b/crates/webcodex-runner/src/webcodex_runner/projects.rs index 3c12f85e7..cd7d37b89 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects.rs @@ -107,6 +107,8 @@ pub(crate) struct RunnerProjectFile { #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) registration_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) registration_incarnation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) description: Option, #[serde(default)] pub(crate) disabled: bool, @@ -294,6 +296,7 @@ mod durability_tests { name: None, kind: None, registration_source: None, + registration_incarnation: None, description: None, disabled: false, hooks: HashMap::new(), diff --git a/crates/webcodex-runner/src/webcodex_runner/projects/catalog.rs b/crates/webcodex-runner/src/webcodex_runner/projects/catalog.rs index b63a7d342..dfe8e94b6 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects/catalog.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects/catalog.rs @@ -85,6 +85,14 @@ pub(crate) fn parse_runner_project_toml(content: &str) -> Result Result { + 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>, @@ -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!( diff --git a/crates/webcodex-runner/src/webcodex_runner/projects/lifecycle.rs b/crates/webcodex-runner/src/webcodex_runner/projects/lifecycle.rs index fcd258c53..a61978be8 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects/lifecycle.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects/lifecycle.rs @@ -40,7 +40,7 @@ fn lifecycle_config_path(project_registry_dir: &Path, id: &str) -> Result Result<(), String> { +pub(super) fn write_existing_project_atomic(path: &Path, content: &str) -> Result<(), String> { let dir = path .parent() .ok_or_else(|| "project config has no parent".to_string())?; @@ -48,10 +48,17 @@ fn write_existing_project_atomic(path: &Path, content: &str) -> Result<(), Strin .file_stem() .and_then(|v| v.to_str()) .unwrap_or("project"); + let existing_permissions = std::fs::metadata(path) + .map_err(|e| format!("failed to inspect existing project config permissions: {e}"))? + .permissions(); let temp = unique_registry_temp(dir, id, "toml.tmp"); let result = (|| { let mut file = std::fs::File::create(&temp) .map_err(|e| format!("failed to create lifecycle temp file: {e}"))?; + // Keep legacy private registry files private after an atomic rewrite. + // Set the mode before writing content so there is no readable window. + file.set_permissions(existing_permissions) + .map_err(|e| format!("failed to preserve project config permissions: {e}"))?; file.write_all(content.as_bytes()) .map_err(|e| format!("failed to write lifecycle temp file: {e}"))?; file.sync_all() @@ -168,6 +175,7 @@ pub(crate) fn handle_project_lifecycle_operation( "operation": action, "agent_project_id": id, "outcome": if desired_disabled {"already_disabled"} else {"already_enabled"}, "changed": false, "revision": current_revision, + "registration_incarnation": project.registration_incarnation, "disabled": project.disabled, "path": project.path, "name": project.name, "kind": project.kind, "registration_source": effective_registration_source(&project).as_str(), @@ -233,6 +241,7 @@ pub(crate) fn handle_project_lifecycle_operation( "operation": action, "agent_project_id": id, "outcome": if desired_disabled {"disabled"} else {"enabled"}, "changed": true, "revision": revision, + "registration_incarnation": project.registration_incarnation, "disabled": project.disabled, "path": project.path, "name": project.name, "kind": project.kind, "registration_source": effective_registration_source(&project).as_str(), @@ -316,6 +325,7 @@ fn recovered_project_result( "outcome": if create { "created" } else { "registered" }, "revision": project_revision(project), "root_fingerprint": root_fingerprint, + "registration_incarnation": project.registration_incarnation, "lineage": project_lineage(project), }) } @@ -435,7 +445,12 @@ pub(crate) fn handle_project_operation( let client_id = client_id.to_string(); let runtime_id = format!("agent:{}:{}", client_id, id); - let toml_content = build_project_toml(&id, &name, &path, &description, allow_patch); + let registration_incarnation = uuid::Uuid::new_v4().to_string(); + let mut toml_content = build_project_toml(&id, &name, &path, &description, allow_patch); + toml_content.push_str(&format!( + "registration_incarnation = \"{}\"\n", + registration_incarnation + )); let template = json .get("template") .and_then(|v| v.as_str()) @@ -528,6 +543,7 @@ pub(crate) fn handle_project_operation( "registration_source": EXPLICIT_REGISTRATION_SOURCE, "revision": project_revision(&parse_runner_project_toml(&toml_content).expect("generated project TOML must parse")), "root_fingerprint": project_root_fingerprint(&canonical), + "registration_incarnation": registration_incarnation, "operation": "register", "outcome": "registered", "changed": true, "recovered": false, }); return ok_cmd(start, result); @@ -733,6 +749,7 @@ pub(crate) fn handle_project_operation( "template": template, "revision": project_revision(&parse_runner_project_toml(&toml_content).expect("generated project TOML must parse")), "root_fingerprint": path_buf.canonicalize().ok().filter(|path| path.is_dir()).as_deref().map(project_root_fingerprint), + "registration_incarnation": registration_incarnation, "git_initialized": git_initialized, "operation": "create", "outcome": "created", "changed": true, "recovered": false, }); diff --git a/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs b/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs index d37948f86..3d25efc70 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects/managed_worktree.rs @@ -254,6 +254,10 @@ fn managed_worktree_project_toml( true, ); content.push_str("managed_worktree = true\n"); + content.push_str(&format!( + "registration_incarnation = \"{}\"\n", + uuid::Uuid::new_v4() + )); content.push_str(&format!("managed_source = {}\n", toml_basic_string(source))); content.push_str(&format!( "managed_source_project_id = {}\n", @@ -305,6 +309,7 @@ fn managed_worktree_success( "disabled": project.disabled, "revision": project_revision(project), "root_fingerprint": project_root_fingerprint(worktree), + "registration_incarnation": project.registration_incarnation, "lineage": project_lineage(project), "source": "managed_worktree", "outcome": outcome, diff --git a/crates/webcodex-runner/src/webcodex_runner/projects/registration.rs b/crates/webcodex-runner/src/webcodex_runner/projects/registration.rs index 9e23d54ce..9741ac930 100644 --- a/crates/webcodex-runner/src/webcodex_runner/projects/registration.rs +++ b/crates/webcodex-runner/src/webcodex_runner/projects/registration.rs @@ -518,6 +518,7 @@ fn path_resolution_success( "disabled": project.disabled, "revision": project_revision(project), "root_fingerprint": project_root_fingerprint(canonical_path), + "registration_incarnation": project.registration_incarnation, "lineage": project_lineage(project), "source": "path", "outcome": outcome, @@ -806,6 +807,11 @@ pub(crate) fn handle_resolve_or_register_project_operation( &description, true, ); + let mut toml_content = toml_content; + toml_content.push_str(&format!( + "registration_incarnation = \"{}\"\n", + uuid::Uuid::new_v4() + )); let write_result = match write_project_toml_atomic(project_registry_dir, &project_id, &toml_content, false) { Ok(result) => result, diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests/runtime_shutdown.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests/runtime_shutdown.rs index adb72b3e9..9d1de8c0c 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests/runtime_shutdown.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests/runtime_shutdown.rs @@ -162,6 +162,7 @@ fn runtime_shutdown_wakes_and_joins_reload_listener() { fn test_project(id: &str) -> RunnerProjectSummary { RunnerProjectSummary { + registration_incarnation: None, id: id.to_string(), name: Some(id.to_string()), path: format!("/tmp/{}", id), diff --git a/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs b/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs index 06d761612..0512b591a 100644 --- a/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs +++ b/crates/webcodex-runner/src/webcodex_runner/transport_tests/stream_telemetry.rs @@ -265,6 +265,7 @@ fn synthetic_project_summary(index: usize, path_bytes: Option) -> RunnerP None => format!("/tmp/project-{index:04}"), }; RunnerProjectSummary { + registration_incarnation: None, id: format!("project-{index:04}"), name: Some(format!("Project {index:04}")), path, diff --git a/crates/webcodex-validation/src/evidence_tests.rs b/crates/webcodex-validation/src/evidence_tests.rs index 3f983b8ff..b24adae7c 100644 --- a/crates/webcodex-validation/src/evidence_tests.rs +++ b/crates/webcodex-validation/src/evidence_tests.rs @@ -3428,6 +3428,7 @@ fn current_evidence_boundary_unavailable_preserves_historical_validation() { store .ensure_coding_session(sessions::CodingSessionRequest { project: "agent:eval:demo".to_string(), + project_identity: None, authority_fingerprint: sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT .to_string(), resume_session_id: Some(session.session_id.clone()), @@ -3692,6 +3693,7 @@ fn record_task_instruction(store: &SessionStore, session_id: &str, instruction: store .ensure_coding_session(sessions::CodingSessionRequest { project: "agent:eval:demo".to_string(), + project_identity: None, authority_fingerprint: sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT .to_string(), resume_session_id: Some(session_id.to_string()), diff --git a/crates/webcodex-workflow-session/src/continuation_tests.rs b/crates/webcodex-workflow-session/src/continuation_tests.rs index bae8a6684..4869a5867 100644 --- a/crates/webcodex-workflow-session/src/continuation_tests.rs +++ b/crates/webcodex-workflow-session/src/continuation_tests.rs @@ -25,6 +25,7 @@ fn create_session(store: &SessionStore, title: &str) -> String { fn add_instruction(store: &SessionStore, session_id: &str, instruction: &str) { store .ensure_coding_session(CodingSessionRequest { + project_identity: None, project: PROJECT.to_string(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.to_string(), resume_session_id: Some(session_id.to_string()), diff --git a/crates/webcodex-workflow-session/src/handoff_brief_tests.rs b/crates/webcodex-workflow-session/src/handoff_brief_tests.rs index 0c238fcaf..fc3c141cc 100644 --- a/crates/webcodex-workflow-session/src/handoff_brief_tests.rs +++ b/crates/webcodex-workflow-session/src/handoff_brief_tests.rs @@ -141,6 +141,7 @@ fn start_session(store: &SessionStore, title: &str) -> String { fn add_instruction(store: &SessionStore, session_id: &str, instruction: &str) { store .ensure_coding_session(CodingSessionRequest { + project_identity: None, project: PROJECT.to_string(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.to_string(), resume_session_id: Some(session_id.to_string()), diff --git a/crates/webcodex-workflow-session/src/identifier_tests.rs b/crates/webcodex-workflow-session/src/identifier_tests.rs index c5ac3bbcf..6b11738fc 100644 --- a/crates/webcodex-workflow-session/src/identifier_tests.rs +++ b/crates/webcodex-workflow-session/src/identifier_tests.rs @@ -171,6 +171,7 @@ async fn legacy_ledger_restores_all_links_and_resumes_with_compact_messages() { ); let resumed = restored .ensure_coding_session(CodingSessionRequest { + project_identity: None, project: "demo".into(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.into(), resume_session_id: Some(legacy_session.clone()), diff --git a/crates/webcodex-workflow-session/src/model.rs b/crates/webcodex-workflow-session/src/model.rs index efffda172..8212b5d70 100644 --- a/crates/webcodex-workflow-session/src/model.rs +++ b/crates/webcodex-workflow-session/src/model.rs @@ -141,6 +141,9 @@ pub struct SessionRecord { pub(crate) durable: Option, pub session_id: String, pub project: Option, + /// Immutable identity of the Project root captured at Session creation. + /// None marks legacy/unproven identity and never authorizes retargeting. + pub project_identity: Option, /// Domain-separated SHA-256 of the canonical creation-time authority group. /// The historical field name is retained only in persistence; in-memory /// mutable/queryable Sessions always carry a canonical fingerprint and never @@ -226,6 +229,7 @@ pub struct ColdSessionRecord { pub(crate) database_revision: u64, pub session_id: String, pub project: Option, + pub project_identity: Option, pub owner_authority_fingerprint: String, pub mode: SessionMode, pub guards: SessionGuards, @@ -267,6 +271,13 @@ impl StoredSession { } } + pub fn project_identity(&self) -> Option<&str> { + match self { + Self::Hot(record) => record.project_identity.as_deref(), + Self::Cold(record) => record.project_identity.as_deref(), + } + } + pub fn owner_authority_fingerprint(&self) -> &str { match self { Self::Hot(record) => record.owner_authority_fingerprint.as_str(), @@ -335,6 +346,7 @@ pub struct SessionDiscoveryPage { #[derive(Debug, Clone)] pub struct SessionCreateOptions { pub project: Option, + pub project_identity: Option, pub owner_authority_fingerprint: Option, pub title: Option, pub mode: SessionMode, @@ -352,6 +364,7 @@ impl SessionCreateOptions { ) -> Self { Self { project, + project_identity: None, owner_authority_fingerprint: None, title, mode, @@ -361,6 +374,11 @@ impl SessionCreateOptions { } } + pub fn with_project_identity(mut self, project_identity: Option) -> Self { + self.project_identity = project_identity; + self + } + pub fn with_project_instructions( mut self, project_instructions: Option, @@ -391,6 +409,7 @@ impl SessionCreateOptions { #[derive(Debug, Clone)] pub struct CodingSessionRequest { pub project: String, + pub project_identity: Option, /// Canonical creation-time authority fence for every caller, including the /// canonical local/dev authority group. pub authority_fingerprint: String, @@ -444,6 +463,9 @@ pub enum CodingSessionError { session_project: Option, request_project: String, }, + ResumeProjectIdentityChanged { + session_id: String, + }, ResumeAuthorityMismatch { session_id: String, }, @@ -543,6 +565,8 @@ impl PersistedSessionSnapshot { pub struct PersistedSessionRecord { pub session_id: String, pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_identity: Option, /// Historical field name retained on disk, but v2 requires the canonical /// domain-separated authority-group fingerprint on every persisted row. pub owner_authority_fingerprint: String, diff --git a/crates/webcodex-workflow-session/src/persistence.rs b/crates/webcodex-workflow-session/src/persistence.rs index 2a95bd643..07e462f85 100644 --- a/crates/webcodex-workflow-session/src/persistence.rs +++ b/crates/webcodex-workflow-session/src/persistence.rs @@ -99,6 +99,7 @@ impl PersistedSessionRecord { Self { session_id: record.session_id.clone(), project: record.project.clone(), + project_identity: record.project_identity.clone(), owner_authority_fingerprint: record.owner_authority_fingerprint.clone(), title: record.title.clone(), mode: record.mode, @@ -165,6 +166,7 @@ impl PersistedSessionRecord { pub fn still_matches_record(&self, record: &SessionRecord) -> bool { self.session_id == record.session_id && self.project == record.project + && self.project_identity == record.project_identity && self.owner_authority_fingerprint == record.owner_authority_fingerprint && self.title == record.title && self.mode == record.mode @@ -226,6 +228,13 @@ impl PersistedSessionRecord { // can enter the in-memory Session store. let owner_authority_fingerprint = sanitize_owner_authority_fingerprint(self.owner_authority_fingerprint)?; + if self + .project_identity + .as_deref() + .is_some_and(|value| !is_lower_hex_sha256(value)) + { + return None; + } let lifecycle = self.lifecycle; let events: VecDeque> = self .events @@ -441,6 +450,7 @@ impl PersistedSessionRecord { durable: None, session_id, project, + project_identity: self.project_identity, owner_authority_fingerprint, title: self.title.map(|value| bound_summary_string(value.trim())), mode: self.mode, @@ -533,6 +543,16 @@ pub fn cold_session_from_persisted( "persisted Session has invalid canonical authority", )) })?; + if persisted + .project_identity + .as_deref() + .is_some_and(|value| !is_lower_hex_sha256(value)) + { + return Err(serde_json::Error::io(io::Error::new( + io::ErrorKind::InvalidData, + "persisted Session has invalid Project identity", + ))); + } let lifecycle = persisted.lifecycle; let raw = Arc::from(serde_json::value::to_raw_value(persisted)?); let revision_raw = Arc::downgrade(&raw); @@ -541,6 +561,7 @@ pub fn cold_session_from_persisted( database_revision: 0, session_id: persisted.session_id.clone(), project: persisted.project.clone(), + project_identity: persisted.project_identity.clone(), owner_authority_fingerprint, mode: persisted.mode, guards: persisted.guards, @@ -569,6 +590,7 @@ pub fn materialize_cold_session( }; if persisted.session_id != record.session_id || persisted.project != record.project + || persisted.project_identity != record.project_identity || persisted.owner_authority_fingerprint != record.owner_authority_fingerprint || persisted.lifecycle != record.lifecycle || persisted.mode != record.mode diff --git a/crates/webcodex-workflow-session/src/persistence/database.rs b/crates/webcodex-workflow-session/src/persistence/database.rs index b1271777f..f8c895d5e 100644 --- a/crates/webcodex-workflow-session/src/persistence/database.rs +++ b/crates/webcodex-workflow-session/src/persistence/database.rs @@ -202,6 +202,7 @@ impl SessionDatabase { if record.session_id != cold.session_id || record.project != cold.project || record.owner_authority_fingerprint != cold.owner_authority_fingerprint + || record.project_identity != cold.project_identity || record.created_at != cold.created_at { return Err(error("Session database identity changed")); @@ -521,6 +522,10 @@ fn state_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(u64, PersistedSe if record.session_id != row.get::<_, String>(2).map_err(error)? || record.project != row.get::<_, Option>(3).map_err(error)? || record.owner_authority_fingerprint != row.get::<_, String>(4).map_err(error)? + || record + .project_identity + .as_deref() + .is_some_and(|v| !is_lower_hex_sha256(v)) || record.lifecycle.as_str() != row.get::<_, String>(5).map_err(error)? || record.created_at != row.get::<_, i64>(6).map_err(error)? || record.updated_at != row.get::<_, i64>(7).map_err(error)? diff --git a/crates/webcodex-workflow-session/src/persistence/resident.rs b/crates/webcodex-workflow-session/src/persistence/resident.rs index 5cfd23eae..7ee3bacd0 100644 --- a/crates/webcodex-workflow-session/src/persistence/resident.rs +++ b/crates/webcodex-workflow-session/src/persistence/resident.rs @@ -11,6 +11,7 @@ pub(super) fn materialize( let PersistedSessionRecord { session_id, project, + project_identity, owner_authority_fingerprint, title, mode, @@ -52,6 +53,7 @@ pub(super) fn materialize( durable: cold.database.as_ref().map(|_| cold.clone()), session_id, project, + project_identity, owner_authority_fingerprint, title, mode, diff --git a/crates/webcodex-workflow-session/src/session_store_tests.rs b/crates/webcodex-workflow-session/src/session_store_tests.rs index 3b5a94bce..f5dc86be0 100644 --- a/crates/webcodex-workflow-session/src/session_store_tests.rs +++ b/crates/webcodex-workflow-session/src/session_store_tests.rs @@ -159,6 +159,7 @@ fn default_session_retention_exceeds_model_summary_window_and_persists() { fn retained_task_instruction_identity_survives_summary_tail_and_fences_later_attempt() { let store = SessionStore::new(10, DEFAULT_RECENT_EVENT_LIMIT); let request = |resume_session_id: Option, instruction: &str| CodingSessionRequest { + project_identity: None, project: "agent:test:attempt-fence".to_string(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.to_string(), resume_session_id, @@ -334,6 +335,7 @@ fn session_store_persists_and_restores_basic_session() { fn coding_git_baseline_and_repository_edit_fact_persist_resume_and_default_legacy_false() { fn request(resume_session_id: Option) -> CodingSessionRequest { CodingSessionRequest { + project_identity: None, project: "agent:oe:private-drop".to_string(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.to_string(), resume_session_id, @@ -499,6 +501,7 @@ fn coding_session_context_precommit_failure_leaves_memory_unchanged() { execution_context: Option, ) -> CodingSessionRequest { CodingSessionRequest { + project_identity: None, project: "agent:oe:private-drop".to_string(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.to_string(), resume_session_id, @@ -1308,6 +1311,7 @@ fn active_session_survives_capacity_churn_and_keeps_exact_identity_and_fences() store.record_tool_call_finished(Some(start), true, &json!({"ok": true}), None, None); let resume = |project: &str, authority_fingerprint: &str| CodingSessionRequest { + project_identity: None, project: project.to_string(), authority_fingerprint: authority_fingerprint.to_string(), resume_session_id: Some(canonical_id.clone()), @@ -1359,6 +1363,7 @@ fn active_session_survives_persistence_restart_after_capacity_churn() { let owner_authority = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let request = |resume_session_id: Option, project: &str, authority: &str| CodingSessionRequest { + project_identity: None, project: project.to_string(), authority_fingerprint: authority.to_string(), resume_session_id, @@ -1931,6 +1936,7 @@ fn commit_instruction_observation( ) -> CodingSessionOutcome { store .ensure_coding_session(CodingSessionRequest { + project_identity: None, project: "agent:instructions:demo".into(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.into(), resume_session_id: session_id.map(str::to_string), diff --git a/crates/webcodex-workflow-session/src/store.rs b/crates/webcodex-workflow-session/src/store.rs index b85673f37..f44995ac5 100644 --- a/crates/webcodex-workflow-session/src/store.rs +++ b/crates/webcodex-workflow-session/src/store.rs @@ -509,6 +509,7 @@ impl SessionStore { durable: None, session_id: session_id.clone(), project: opts.project, + project_identity: opts.project_identity, owner_authority_fingerprint, title: opts.title, mode: opts.mode, @@ -617,6 +618,9 @@ impl SessionStore { request_project: request.project.clone(), }); } + if stored.project_identity() != request.project_identity.as_deref() { + return Err(CodingSessionError::ResumeProjectIdentityChanged { session_id }); + } Some(session_id) } else { None @@ -786,6 +790,7 @@ impl SessionStore { durable: None, session_id: new_session_id.clone(), project: Some(request.project.clone()), + project_identity: request.project_identity.clone(), owner_authority_fingerprint: request.authority_fingerprint.clone(), // The first accepted instruction remains the root title. // Follow-up instructions never overwrite it. @@ -1337,6 +1342,14 @@ impl SessionStore { inner.session_project(session_id) } + pub fn session_project_identity(&self, session_id: &str) -> Option> { + let inner = self.inner.lock().expect("session store mutex poisoned"); + inner + .sessions + .get(session_id) + .map(|s| s.project_identity().map(str::to_string)) + } + pub fn session_target_authority(&self, session_id: &str) -> Option<(Option, String)> { let inner = self.inner.lock().expect("session store mutex poisoned"); inner.session_target_authority(session_id) diff --git a/crates/webcodex-workflow-session/src/store/writer/sqlite_tests.rs b/crates/webcodex-workflow-session/src/store/writer/sqlite_tests.rs index a902dc29b..1375cee34 100644 --- a/crates/webcodex-workflow-session/src/store/writer/sqlite_tests.rs +++ b/crates/webcodex-workflow-session/src/store/writer/sqlite_tests.rs @@ -94,6 +94,7 @@ async fn durable_observation_of_unchanged_state_does_not_write() { fn latest_instruction_and_closed_identity_survive_history_and_session_churn() { let (temporary, store) = persistent_fixture(Duration::from_secs(60)); let request = CodingSessionRequest { + project_identity: None, project: "agent:scale:populated".into(), authority_fingerprint: TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT.into(), resume_session_id: None, diff --git a/src/admin_project_lifecycle.rs b/src/admin_project_lifecycle.rs index f08b5a709..b09b50b65 100644 --- a/src/admin_project_lifecycle.rs +++ b/src/admin_project_lifecycle.rs @@ -654,7 +654,10 @@ fn map_create_result( } fn lifecycle_summary(output: &Value, id: &str) -> Option { + let incarnation = output.get("registration_incarnation")?.as_str()?; + uuid::Uuid::parse_str(incarnation).ok()?; Some(RunnerProjectSummary { + registration_incarnation: Some(incarnation.to_string()), id: id.to_string(), name: output .get("name") @@ -992,6 +995,39 @@ mod tests { ); } + #[test] + fn lifecycle_projection_requires_authoritative_registration_identity() { + let complete = json!({ + "path": "/original-root", + "name": "demo", + "disabled": false, + "registration_incarnation": "0a03dd75-3c93-4e91-a4ba-130592d5bc7e" + }); + assert_eq!( + lifecycle_summary(&complete, "demo") + .unwrap() + .registration_incarnation + .as_deref(), + Some("0a03dd75-3c93-4e91-a4ba-130592d5bc7e") + ); + + let mut missing = complete.clone(); + missing + .as_object_mut() + .unwrap() + .remove("registration_incarnation"); + assert!( + lifecycle_summary(&missing, "demo").is_none(), + "an incomplete lifecycle receipt must not erase a known Registry incarnation" + ); + let mut malformed = complete; + malformed["registration_incarnation"] = json!("not-an-incarnation"); + assert!( + lifecycle_summary(&malformed, "demo").is_none(), + "invalid registration identity must fail closed before a Registry update" + ); + } + #[test] fn project_lifecycle_error_mapping_is_stable_and_safe() { assert_eq!(map_agent_error("agent_unavailable").status, 503); diff --git a/src/mcp_tests/artifact_export.rs b/src/mcp_tests/artifact_export.rs index bbca9a941..f83f628da 100644 --- a/src/mcp_tests/artifact_export.rs +++ b/src/mcp_tests/artifact_export.rs @@ -42,6 +42,7 @@ async fn mcp_export_runtime_inner( "exporter", "inst-export", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: Some("demo".to_string()), path: root.to_string_lossy().into_owned(), diff --git a/src/mcp_tests/cli/peer_clients.rs b/src/mcp_tests/cli/peer_clients.rs index 7fd5a1203..0ab3142ec 100644 --- a/src/mcp_tests/cli/peer_clients.rs +++ b/src/mcp_tests/cli/peer_clients.rs @@ -146,6 +146,7 @@ fn chatgpt_codex_and_claude_discover_message_and_ack_through_real_mcp() { }).await.unwrap(); crate::test_support::apply_project_inventory_snapshot(&runtime.runner_registry, "peer-client-runner", "instance", vec![RunnerProjectSummary { + registration_incarnation: None, id: "repo".into(), name: Some("Peer fixture".into()), path: temp.path().to_string_lossy().to_string(), allow_patch: true, kind: Some("repo".into()), registration_source: None, description: None, hooks: Vec::new(), disabled: false, revision: None, root_fingerprint: None, lineage: None, diff --git a/src/mcp_tests/external_observations.rs b/src/mcp_tests/external_observations.rs index 5939ed6b0..7e4f6576f 100644 --- a/src/mcp_tests/external_observations.rs +++ b/src/mcp_tests/external_observations.rs @@ -33,7 +33,8 @@ async fn roundtrip() { .with_communication_database(db.clone()), ); let project = crate::tool_runtime::runner_project_runtime_id("mcp-observation-agent", "shared"); - let session = start_mcp_fixture_session(&runtime, Some(&project), "External observations"); + let session = + start_mcp_fixture_session(&runtime, Some(&project), "External observations").await; let service = Service::new( build_test_router(config, db, runtime).push( Router::with_path("api/tools/call") diff --git a/src/mcp_tests/file_import.rs b/src/mcp_tests/file_import.rs index e77e03efb..a82041928 100644 --- a/src/mcp_tests/file_import.rs +++ b/src/mcp_tests/file_import.rs @@ -291,6 +291,7 @@ async fn mcp_import_runtime_inner( "importer", "inst-import", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: Some("demo".to_string()), path: root.to_string_lossy().into_owned(), diff --git a/src/mcp_tests/fixed_scope.rs b/src/mcp_tests/fixed_scope.rs index 2f292f683..cbe77f862 100644 --- a/src/mcp_tests/fixed_scope.rs +++ b/src/mcp_tests/fixed_scope.rs @@ -70,6 +70,7 @@ async fn scope_fixture_runner( client_id, &format!("inst-{client_id}"), vec![RunnerProjectSummary { + registration_incarnation: None, id: "repo".to_string(), name: None, path: format!("/scope-fixture/{client_id}"), diff --git a/src/mcp_tests/http_transport.rs b/src/mcp_tests/http_transport.rs index d8df3ae1d..6a7b65c50 100644 --- a/src/mcp_tests/http_transport.rs +++ b/src/mcp_tests/http_transport.rs @@ -138,6 +138,7 @@ async fn stateless_observation_runner_registry() -> Arc Arc, title: &str) -> String { +async fn start_mcp_fixture_session( + runtime: &ToolRuntime, + project: Option<&str>, + title: &str, +) -> String { + let project_identity = match project { + Some(project) => { + crate::test_support::registered_session_project_identity(runtime, project, None).await + } + None => None, + }; let mut auth = crate::auth::AuthContext::new(crate::auth::AuthKind::Bootstrap); auth.is_bootstrap = true; let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&auth)) @@ -276,7 +288,8 @@ fn start_mcp_fixture_session(runtime: &ToolRuntime, project: Option<&str>, title crate::tool_runtime::SessionMode::Normal, crate::tool_runtime::sessions::SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(fingerprint)), + .with_owner_authority_fingerprint(Some(fingerprint)) + .with_project_identity(project_identity), ) .unwrap() .session_id @@ -1194,7 +1207,7 @@ async fn http_mcp_2026_request_scoped_ack_redelivers_until_durable_resolution_bo let runtime = Arc::new(test_runtime()); let service = Service::new(build_test_router(config, db, runtime.clone())); - let session_id = start_mcp_fixture_session(&runtime, None, "ACK dogfood"); + let session_id = start_mcp_fixture_session(&runtime, None, "ACK dogfood").await; let (status, post_body) = stateless_2026_tool_call( &service, @@ -1586,7 +1599,7 @@ async fn http_mcp_2026_explicit_handoff_without_context_ack_body() { let (_tmp, db) = test_db(); let runtime = Arc::new(test_runtime()); let service = Service::new(build_test_router(config, db, runtime.clone())); - let session_id = start_mcp_fixture_session(&runtime, None, "explicit recovery"); + let session_id = start_mcp_fixture_session(&runtime, None, "explicit recovery").await; let (status, posted) = stateless_2026_tool_call( &service, "secret", @@ -1680,9 +1693,9 @@ async fn http_mcp_2026_collaboration_completion_preserves_explicit_recorder_prov let runtime = Arc::new(test_runtime()); let service = Service::new(build_test_router(config, db, runtime.clone())); - let coordinator_id = start_mcp_fixture_session(&runtime, None, "Coordinator C"); - let worker_id = start_mcp_fixture_session(&runtime, None, "Worker W"); - let replay_worker_id = start_mcp_fixture_session(&runtime, None, "Replay worker X"); + let coordinator_id = start_mcp_fixture_session(&runtime, None, "Coordinator C").await; + let worker_id = start_mcp_fixture_session(&runtime, None, "Worker W").await; + let replay_worker_id = start_mcp_fixture_session(&runtime, None, "Replay worker X").await; let (status, posted_body) = stateless_2026_tool_call( &service, @@ -1999,19 +2012,22 @@ async fn http_mcp_2026_observe_session_messages_preserves_stateless_delta_contra // The assertions below still exercise project resolution and immutable recorder authority // through the real stateless collaboration calls. let coordinator_id = - start_mcp_fixture_session(&runtime, Some(&shared_project), "Observation coordinator C"); + start_mcp_fixture_session(&runtime, Some(&shared_project), "Observation coordinator C") + .await; let worker_id = - start_mcp_fixture_session(&runtime, Some(&shared_project), "Observation worker W"); + start_mcp_fixture_session(&runtime, Some(&shared_project), "Observation worker W").await; let second_coordinator_id = start_mcp_fixture_session( &runtime, Some(&shared_project), "Observation coordinator C2", - ); + ) + .await; let foreign_worker_id = start_mcp_fixture_session( &runtime, Some(&foreign_project), "Foreign observation worker W2", - ); + ) + .await; let pre_baseline_body_marker = "pre-baseline-history-must-not-replay"; let (status, pre_baseline_body) = stateless_2026_tool_call( diff --git a/src/mcp_tests/http_transport/client_window.rs b/src/mcp_tests/http_transport/client_window.rs index 92f0abd3f..75ca98578 100644 --- a/src/mcp_tests/http_transport/client_window.rs +++ b/src/mcp_tests/http_transport/client_window.rs @@ -690,6 +690,7 @@ async fn supplied_window_identity_cannot_grant_foreign_project_or_session_author "identity-owner-runner", "identity-owner-instance", vec![RunnerProjectSummary { + registration_incarnation: None, id: "repo".into(), name: None, path: "/identity-fixture/repo".into(), diff --git a/src/mcp_tests/http_transport/work_result_thread.rs b/src/mcp_tests/http_transport/work_result_thread.rs index 8ad944e95..f4120716e 100644 --- a/src/mcp_tests/http_transport/work_result_thread.rs +++ b/src/mcp_tests/http_transport/work_result_thread.rs @@ -67,7 +67,7 @@ async fn exercise() { ); let project = "agent:mcp-observation-agent:shared"; let window = "thread-panel-host-window"; - let session = start_mcp_fixture_session(&runtime, Some(project), "Thread panel binding"); + let session = start_mcp_fixture_session(&runtime, Some(project), "Thread panel binding").await; let session_events = runtime .sessions .summary(&session, None) diff --git a/src/mcp_tests/model_ergonomics.rs b/src/mcp_tests/model_ergonomics.rs index dc31bd312..a1f1e5d6a 100644 --- a/src/mcp_tests/model_ergonomics.rs +++ b/src/mcp_tests/model_ergonomics.rs @@ -187,6 +187,7 @@ async fn model_ergonomics_normalization_reaches_api_and_mcp_action_audit_without "input-audit", "inst-input-audit", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: None, path: "/tmp/input-audit".to_string(), @@ -683,6 +684,7 @@ async fn http_mcp_code_mode_persists_only_bounded_composition_telemetry() { "code-mode-audit", "inst-code-mode-audit", vec![RunnerProjectSummary { + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), id: "demo".to_string(), name: Some("Code Mode audit".to_string()), path: "/tmp/code-mode-audit".to_string(), @@ -714,6 +716,12 @@ async fn http_mcp_code_mode_persists_only_bounded_composition_telemetry() { is_bootstrap: true, ..crate::auth::AuthContext::new(crate::auth::AuthKind::Bootstrap) }; + let project_identity = crate::test_support::registered_session_project_identity( + &runtime, + exact_project, + Some(&auth), + ) + .await; let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&auth)) .expect("bootstrap test authority"); let session = runtime @@ -725,7 +733,8 @@ async fn http_mcp_code_mode_persists_only_bounded_composition_telemetry() { crate::tool_runtime::SessionMode::ReadOnly, crate::tool_runtime::SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(fingerprint)), + .with_owner_authority_fingerprint(Some(fingerprint)) + .with_project_identity(project_identity), ) .unwrap(); let service = Service::new(build_test_router(config, db.clone(), runtime)); diff --git a/src/mcp_tests/project_discovery.rs b/src/mcp_tests/project_discovery.rs index 45cfaee49..3e7921659 100644 --- a/src/mcp_tests/project_discovery.rs +++ b/src/mcp_tests/project_discovery.rs @@ -60,6 +60,7 @@ async fn bounded_project_discovery_mcp_surfaces() { format!("project-{index:04}") }; RunnerProjectSummary { + registration_incarnation: None, name: Some(format!("Large Fixture Project {index:04}")), path: format!("/tmp/large-fixture/{id}"), id, diff --git a/src/mcp_tests/request_policy.rs b/src/mcp_tests/request_policy.rs index 72be7b406..b950faed5 100644 --- a/src/mcp_tests/request_policy.rs +++ b/src/mcp_tests/request_policy.rs @@ -316,6 +316,7 @@ async fn request_policy_pending_wait_and_terminal_observe_keep_one_execution_wit "request-policy", "inst-policy", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".into(), name: None, path: "/tmp/request-policy".into(), diff --git a/src/mcp_tests/result_app.rs b/src/mcp_tests/result_app.rs index 204903273..b348bade3 100644 --- a/src/mcp_tests/result_app.rs +++ b/src/mcp_tests/result_app.rs @@ -1771,6 +1771,7 @@ async fn register_job_runner(runtime: &ToolRuntime, auth: &crate::auth::AuthCont "result-app-runner", "inst-result-app", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: Some("Result App Demo".to_string()), path: "/tmp/result-app-demo".to_string(), diff --git a/src/mcp_tests/runner_discovery.rs b/src/mcp_tests/runner_discovery.rs index 587cc2b30..8e2e8a5e6 100644 --- a/src/mcp_tests/runner_discovery.rs +++ b/src/mcp_tests/runner_discovery.rs @@ -24,6 +24,7 @@ async fn mcp_runner_discovery_default_211_projection_bytes() { .map(|index| { let id = format!("project-{index:03}"); RunnerProjectSummary { + registration_incarnation: None, name: Some(id.clone()), path: format!("/fixture/{id}"), id, diff --git a/src/mcp_tests/structured_failure.rs b/src/mcp_tests/structured_failure.rs index 41e82f8a6..a694ceeba 100644 --- a/src/mcp_tests/structured_failure.rs +++ b/src/mcp_tests/structured_failure.rs @@ -341,6 +341,7 @@ async fn register_failure_runner_with_resource_links( "failure-runner", "inst", vec![RunnerProjectSummary { + registration_incarnation: None, id: "probe".into(), name: None, path: "/tmp/structured-failure-probe".into(), diff --git a/src/mcp_tests/tools.rs b/src/mcp_tests/tools.rs index 9994e269e..5ef6b3e7d 100644 --- a/src/mcp_tests/tools.rs +++ b/src/mcp_tests/tools.rs @@ -442,6 +442,7 @@ async fn hidden_extensions_keep_exact_manifest_and_gateway_execution() { "hidden-extension-runner", "inst", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: None, path: tmp.path().display().to_string(), @@ -1270,6 +1271,7 @@ async fn project_artifact_image_call_returns_native_image_for_remote_agent_proje client_id, runner_instance_id, vec![RunnerProjectSummary { + registration_incarnation: None, id: project_name.to_string(), name: Some(project_name.to_string()), path: "/remote/session-atlas".to_string(), @@ -3466,6 +3468,7 @@ async fn mcp_read_files_ignores_inapplicable_context_ack_without_consuming_it() client_id, runner_instance_id, vec![RunnerProjectSummary { + registration_incarnation: None, id: project_name.to_string(), name: Some(project_name.to_string()), path: "/remote/repo".to_string(), @@ -3889,6 +3892,7 @@ async fn mcp_show_changes_distinguishes_recording_session_id_from_query_session_ "mcp-client", "inst", vec![RunnerProjectSummary { + registration_incarnation: None, id: "demo".to_string(), name: Some("demo".to_string()), path: "/tmp/mcp-demo".to_string(), @@ -4065,6 +4069,7 @@ async fn managed_worktree_project_grant_admission_preserves_exact_and_root_autho CLIENT, INSTANCE, vec![RunnerProjectSummary { + registration_incarnation: None, id: PROJECT.into(), name: Some(PROJECT.into()), path: source.to_string_lossy().into_owned(), @@ -4196,6 +4201,7 @@ async fn managed_worktree_project_grant_admission_preserves_exact_and_root_autho CLIENT, INSTANCE, vec![RunnerProjectSummary { + registration_incarnation: None, id: PROJECT.into(), name: Some(PROJECT.into()), path: source.to_string_lossy().into_owned(), @@ -4525,6 +4531,7 @@ async fn project_grant_authority_is_identical_for_project_credential_and_share_o }) }; let project = |id: &str, path: &str| RunnerProjectSummary { + registration_incarnation: None, id: id.to_string(), name: Some(id.to_string()), path: path.to_string(), diff --git a/src/runner_http/mod_tests.rs b/src/runner_http/mod_tests.rs index 494c46868..3324f6716 100644 --- a/src/runner_http/mod_tests.rs +++ b/src/runner_http/mod_tests.rs @@ -88,6 +88,7 @@ fn oauth_bridge_auth_context(hash: &str, scopes: Vec<&str>) -> crate::auth::Auth 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(), diff --git a/src/runtime_console_http/tests/job_queries.rs b/src/runtime_console_http/tests/job_queries.rs index 6e3854710..6d86f923c 100644 --- a/src/runtime_console_http/tests/job_queries.rs +++ b/src/runtime_console_http/tests/job_queries.rs @@ -22,7 +22,7 @@ async fn console_job_query_uses_the_same_authorized_inventory_without_model_only ) .await; let project = "agent:job-query-own:demo"; - let session = start_authorized_session(&runtime, project, &auth); + let session = start_authorized_session(&runtime, project, &auth).await; let started = runtime .dispatch_with_auth( ToolCall::RunJob { diff --git a/src/runtime_console_http/tests/mod.rs b/src/runtime_console_http/tests/mod.rs index ba8a26158..3e4ab6484 100644 --- a/src/runtime_console_http/tests/mod.rs +++ b/src/runtime_console_http/tests/mod.rs @@ -34,6 +34,7 @@ mod window_visibility; fn project(id: &str, private_path: &str) -> RunnerProjectSummary { RunnerProjectSummary { + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), id: id.to_string(), name: Some(format!("Project {id}")), path: private_path.to_string(), @@ -308,11 +309,14 @@ fn test_bootstrap_auth() -> AuthContext { auth } -fn start_authorized_session( +async fn start_authorized_session( runtime: &ToolRuntime, project: &str, auth: &AuthContext, ) -> crate::tool_runtime::sessions::SessionSummary { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; let fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(auth)) .expect("stable test authority"); runtime @@ -324,7 +328,8 @@ fn start_authorized_session( SessionMode::Normal, SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(fingerprint)), + .with_owner_authority_fingerprint(Some(fingerprint)) + .with_project_identity(project_identity), ) .unwrap() } diff --git a/src/runtime_console_http/tests/overview.rs b/src/runtime_console_http/tests/overview.rs index 5874854e6..f873fbbd8 100644 --- a/src/runtime_console_http/tests/overview.rs +++ b/src/runtime_console_http/tests/overview.rs @@ -278,7 +278,7 @@ async fn navigation_overview_does_not_project_projects_or_scan_job_history() { ) .await; for _ in 0..20 { - start_authorized_session(&runtime, "agent:own:repo", &auth); + start_authorized_session(&runtime, "agent:own:repo", &auth).await; } let before_jobs = runtime .runner_registry @@ -423,7 +423,7 @@ async fn runtime_home_primary_skips_session_hydration_but_keeps_visibility_and_p Some(&foreign), ) .await; - start_authorized_session(&runtime, "agent:own:one", &auth); + start_authorized_session(&runtime, "agent:own:one", &auth).await; let primary = overview_for_auth_detail(&runtime, &auth, false) .await .unwrap(); @@ -453,8 +453,8 @@ async fn runtime_home_projects_and_recent_sessions_span_visible_runners() { let auth = crate::auth::shared_key_context("runtime-home-fleet"); register_project(&runtime, "runner-a", "proj-a", "/private/a", Some(&auth)).await; register_project(&runtime, "runner-b", "proj-b", "/private/b", Some(&auth)).await; - start_authorized_session(&runtime, "agent:runner-a:proj-a", &auth); - start_authorized_session(&runtime, "agent:runner-b:proj-b", &auth); + start_authorized_session(&runtime, "agent:runner-a:proj-a", &auth).await; + start_authorized_session(&runtime, "agent:runner-b:proj-b", &auth).await; let home = overview_for_auth(&runtime, &auth).await.unwrap(); let recent_clients = home @@ -494,8 +494,8 @@ async fn runtime_home_recent_sessions_follow_project_authority() { let auth_b = crate::auth::shared_key_context("runtime-home-b"); register_project(&runtime, "runner-a", "proj-a", "/private/a", Some(&auth_a)).await; register_project(&runtime, "runner-b", "proj-b", "/private/b", Some(&auth_b)).await; - start_authorized_session(&runtime, "agent:runner-a:proj-a", &auth_a); - start_authorized_session(&runtime, "agent:runner-b:proj-b", &auth_b); + start_authorized_session(&runtime, "agent:runner-a:proj-a", &auth_a).await; + start_authorized_session(&runtime, "agent:runner-b:proj-b", &auth_b).await; let home = overview_for_auth(&runtime, &auth_a).await.unwrap(); assert_eq!(home.projects.len(), 1); diff --git a/src/runtime_console_http/tests/session_collaboration.rs b/src/runtime_console_http/tests/session_collaboration.rs index 7440d09b6..4483ccf6a 100644 --- a/src/runtime_console_http/tests/session_collaboration.rs +++ b/src/runtime_console_http/tests/session_collaboration.rs @@ -59,7 +59,7 @@ async fn collaboration_message_projection_reuses_authority_fence_and_hides_compl let auth_b = crate::auth::shared_key_context("runtime-console-group-b"); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; - let session = start_authorized_session(&runtime, project_id, &auth_a); + let session = start_authorized_session(&runtime, project_id, &auth_a).await; let todo = runtime .sessions .post_message(PostSessionMessageInput { @@ -150,7 +150,7 @@ async fn human_join_reuses_formal_session_authority_and_ack_validation() { let auth_b = crate::auth::shared_key_context("runtime-console-human-b"); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; - let session = start_authorized_session(&runtime, project_id, &auth_a); + let session = start_authorized_session(&runtime, project_id, &auth_a).await; let posted = session_post_message_for_auth( &runtime, @@ -271,7 +271,7 @@ async fn browser_message_mutation_routes_succeed_and_retain_history() { let auth = test_bootstrap_auth(); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; - let session = start_authorized_session(&runtime, project_id, &auth); + let session = start_authorized_session(&runtime, project_id, &auth).await; let withdraw_target = runtime .sessions .post_message(PostSessionMessageInput { @@ -373,7 +373,7 @@ async fn browser_message_mutations_fail_closed_on_authority_and_state_conflicts( let runtime_read_only = scoped_oauth(&[SCOPE_RUNTIME_READ, SCOPE_PROJECT_READ]); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; - let session = start_authorized_session(&runtime, project_id, &auth_a); + let session = start_authorized_session(&runtime, project_id, &auth_a).await; let note = runtime .sessions .post_message(PostSessionMessageInput { @@ -610,7 +610,7 @@ async fn browser_message_mutation_persistence_uncertain_is_503_and_retains_live_ let auth = test_bootstrap_auth(); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; - let session = start_authorized_session(&runtime, project_id, &auth); + let session = start_authorized_session(&runtime, project_id, &auth).await; let message = runtime .sessions .post_message(PostSessionMessageInput { @@ -668,7 +668,7 @@ async fn collaboration_observation_route_preserves_baseline_update_timeout_and_p let auth = crate::auth::shared_key_context("runtime-console-observe"); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; - let session = start_authorized_session(&runtime, project_id, &auth); + let session = start_authorized_session(&runtime, project_id, &auth).await; let baseline = session_observe_for_auth( &runtime, @@ -783,7 +783,7 @@ async fn collaboration_observation_route_keeps_history_after_message_churn() { let auth = crate::auth::shared_key_context("runtime-console-history-loss"); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth)).await; - let session = start_authorized_session(&runtime, project_id, &auth); + let session = start_authorized_session(&runtime, project_id, &auth).await; let baseline = session_observe_for_auth( &runtime, &auth, diff --git a/src/runtime_http_tests.rs b/src/runtime_http_tests.rs index 1b007ed9d..6eaef6639 100644 --- a/src/runtime_http_tests.rs +++ b/src/runtime_http_tests.rs @@ -282,6 +282,7 @@ async fn register_import_agent_with_capabilities( "importer", "inst-import", vec![RunnerProjectSummary { + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), id: "demo".to_string(), name: Some("demo".to_string()), path: root.to_string_lossy().to_string(), @@ -632,6 +633,7 @@ fn http_runtime_status_after_runner_registration_fits_default_worker_stack() { "status-stack-runner", "status-stack-instance", vec![RunnerProjectSummary { + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), id: "smoke-proj".to_string(), name: Some("Smoke Project".to_string()), path: "/tmp/status-stack-project".to_string(), diff --git a/src/test_support.rs b/src/test_support.rs index 8fd172a4b..2c9c71a61 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -8,6 +8,25 @@ use std::path::PathBuf; use std::sync::Arc; +/// Bind a manually seeded Session fixture to the Project root already +/// registered by its Runner. This models the real Session creation boundary; +/// an unknown Project stays unbound rather than guessing an identity. +pub(crate) async fn registered_session_project_identity( + runtime: &crate::tool_runtime::ToolRuntime, + project: &str, + auth: Option<&crate::auth::AuthContext>, +) -> Option { + let resolved = runtime + .resolve_project_input_for_auth(project, auth) + .await + .ok()?; + crate::tool_runtime::workflow_project_identity( + resolved.root_fingerprint.as_deref(), + &resolved.config.path, + resolved.registration_incarnation.as_deref(), + ) +} + /// Create a test temp directory on the Cargo build filesystem. /// /// Some hardened hosts mount the system temp directory with `noexec`. Tests diff --git a/src/tool_runtime/admin_dashboard.rs b/src/tool_runtime/admin_dashboard.rs index 957afe360..6de4bd122 100644 --- a/src/tool_runtime/admin_dashboard.rs +++ b/src/tool_runtime/admin_dashboard.rs @@ -273,6 +273,7 @@ mod tests { fn project(id: &str, disabled: bool) -> RunnerProjectSummary { RunnerProjectSummary { + registration_incarnation: None, id: id.to_string(), name: Some(id.to_string()), path: format!("/tmp/{id}"), diff --git a/src/tool_runtime/coding_task.rs b/src/tool_runtime/coding_task.rs index 7493aecf3..cbf562d96 100644 --- a/src/tool_runtime/coding_task.rs +++ b/src/tool_runtime/coding_task.rs @@ -31,8 +31,8 @@ use super::project_resolution::ResolvedProject; use super::runtime_info::compact_runtime_status; use super::session_context::{ absent_workflow_session_result, session_project_mismatch_result, - session_retention_expired_result, workflow_session_authority_fingerprint, - SessionProjectMismatch, + session_retention_expired_result, workflow_project_identity, + workflow_session_authority_fingerprint, SessionProjectMismatch, }; use super::sessions::{self, SessionTransport, TOOL_CALL_RECORDING_SESSION_ID_FIELD}; use super::startup_brief::{ diff --git a/src/tool_runtime/coding_task/startup.rs b/src/tool_runtime/coding_task/startup.rs index 44fb4c63b..a317500d1 100644 --- a/src/tool_runtime/coding_task/startup.rs +++ b/src/tool_runtime/coding_task/startup.rs @@ -423,6 +423,20 @@ impl ToolRuntime { ); } }; + let project_identity = workflow_project_identity( + resolved.root_fingerprint.as_deref(), + &resolved.config.path, + resolved.registration_incarnation.as_deref(), + ); + if project_identity.is_none() { + return attach_project_resolution( + ToolResult::err_with_output( + "project_registration_identity_unavailable", + json!({"error_kind": "project_registration_identity_unavailable", "state_changed": false}), + ), + &project_resolution, + ); + } if self.sessions.prepare_request().await.is_err() { return attach_project_resolution( crate::tool_runtime::session_tools::session_storage_unavailable_result(), @@ -432,6 +446,7 @@ impl ToolRuntime { let session_outcome = match self.sessions.ensure_coding_session_with_git_baseline( sessions::CodingSessionRequest { project: resolved.resolved_id.clone(), + project_identity, authority_fingerprint, resume_session_id: resume_session_id.clone(), instruction: title.clone(), @@ -956,6 +971,18 @@ fn coding_session_start_error( }), ) } + sessions::CodingSessionError::ResumeProjectIdentityChanged { session_id } => { + ToolResult::err_with_output( + "session_project_identity_changed: existing Session is not bound to the current Project registration", + json!({ + "error_kind": "session_project_identity_changed", + "failure_kind": "session_project_identity_changed", + "session_id": session_id, + "resume_requested": true, + "state_changed": false, + }), + ) + } sessions::CodingSessionError::ResumeAuthorityMismatch { session_id } => { ToolResult::err_with_output( "session_authority_denied", diff --git a/src/tool_runtime/coding_task/tests.rs b/src/tool_runtime/coding_task/tests.rs index bf564e265..3c6a33fc8 100644 --- a/src/tool_runtime/coding_task/tests.rs +++ b/src/tool_runtime/coding_task/tests.rs @@ -210,6 +210,7 @@ fn resolved_agent(client_id: &str) -> ResolvedProject { allow_patch: true, }, root_fingerprint: None, + registration_incarnation: None, knowledge_association: None, } } diff --git a/src/tool_runtime/memory.rs b/src/tool_runtime/memory.rs index 5a83f31ae..e5bf18614 100644 --- a/src/tool_runtime/memory.rs +++ b/src/tool_runtime/memory.rs @@ -771,6 +771,7 @@ mod tests { allow_patch: true, }, root_fingerprint: None, + registration_incarnation: None, knowledge_association: None, } } diff --git a/src/tool_runtime/mod.rs b/src/tool_runtime/mod.rs index cece0a988..ee2e49fd8 100644 --- a/src/tool_runtime/mod.rs +++ b/src/tool_runtime/mod.rs @@ -174,6 +174,8 @@ pub(crate) use runtime_compatibility::build_alignment_name; pub use runtime_info::RuntimeInfo; pub(crate) use runtime_info::{ListRunnersOptions, RunnerInventoryPage, RunnerInventoryRow}; #[cfg(test)] +pub(crate) use session_context::workflow_project_identity; +#[cfg(test)] pub(crate) use session_context::workflow_session_authority_fingerprint; #[cfg(test)] pub(crate) use sessions::{SessionCreateOptions, SessionGuards, SessionSummary}; diff --git a/src/tool_runtime/project_resolution.rs b/src/tool_runtime/project_resolution.rs index 237cabcbf..cccff5409 100644 --- a/src/tool_runtime/project_resolution.rs +++ b/src/tool_runtime/project_resolution.rs @@ -15,6 +15,7 @@ pub(crate) struct ProjectResolverCandidate { pub(crate) path: String, pub(crate) allow_patch: bool, pub(crate) root_fingerprint: Option, + pub(crate) registration_incarnation: Option, pub(crate) lineage: Option, pub(crate) connected: bool, pub(crate) status: String, @@ -65,6 +66,7 @@ pub(crate) struct ResolvedProject { /// produced this resolution. It is descriptive only and never authorizes /// access by itself. pub(crate) root_fingerprint: Option, + pub(crate) registration_incarnation: Option, /// Descriptive Runner-owned lineage only. It never changes execution cwd, /// ProjectConfig, Session authority, Plugin placement, or tool permission. pub(crate) knowledge_association: Option, @@ -450,6 +452,7 @@ impl ToolRuntime { path: project.path.clone(), allow_patch: project.allow_patch, root_fingerprint: project.root_fingerprint.clone(), + registration_incarnation: project.registration_incarnation.clone(), lineage: project.lineage.clone(), connected: client.connected, status: client.status.clone(), @@ -474,6 +477,7 @@ impl ToolRuntime { resolved_id: candidate.id.clone(), config: Self::project_config_from_candidate(candidate), root_fingerprint: candidate.root_fingerprint.clone(), + registration_incarnation: candidate.registration_incarnation.clone(), knowledge_association: candidate.lineage.clone(), } } diff --git a/src/tool_runtime/projects.rs b/src/tool_runtime/projects.rs index a5d9bbfa2..13dbc828a 100644 --- a/src/tool_runtime/projects.rs +++ b/src/tool_runtime/projects.rs @@ -1565,6 +1565,10 @@ fn parse_project_summary_from_result( Some(value) => Some(serde_json::from_value(value.clone()).ok()?), }; Some(RunnerProjectSummary { + registration_incarnation: result + .get("registration_incarnation") + .and_then(Value::as_str) + .map(str::to_string), id: agent_project_id.to_string(), name: name.or_else(|| Some(agent_project_id.to_string())), path: path.to_string(), @@ -1610,6 +1614,7 @@ mod tests { #[test] fn legacy_managed_temporary_kind_is_projected_as_ordinary_registration() { let project = RunnerProjectSummary { + registration_incarnation: None, id: "legacy".to_string(), name: Some("Legacy".to_string()), path: "/tmp/legacy".to_string(), @@ -1634,6 +1639,7 @@ mod tests { #[test] fn old_runner_auto_registered_kind_is_compatibility_fallback() { let mut project = RunnerProjectSummary { + registration_incarnation: None, id: "legacy-auto".to_string(), name: Some("Legacy Auto".to_string()), path: "/tmp/legacy-auto".to_string(), diff --git a/src/tool_runtime/session_context.rs b/src/tool_runtime/session_context.rs index cffdc877c..f05998fa1 100644 --- a/src/tool_runtime/session_context.rs +++ b/src/tool_runtime/session_context.rs @@ -618,6 +618,26 @@ pub(crate) fn workflow_session_authority_fingerprint( )) } +/// Scope a Project-bound Session to a single persisted Runner registration. +/// A root path alone cannot prove continuity after unregister/re-register. +pub(crate) fn workflow_project_identity( + root_fingerprint: Option<&str>, + registered_path: &str, + registration_incarnation: Option<&str>, +) -> Option { + let incarnation = registration_incarnation.filter(|value| !value.is_empty())?; + let root = root_fingerprint.filter(|fp| !fp.trim().is_empty()); + Some(hash_authority_identity( + b"webcodex.workflow-project-registration.v2\0", + if root.is_some() { + "canonical-root" + } else { + "registered-path" + }, + &format!("{}\0{}", incarnation, root.unwrap_or(registered_path)), + )) +} + pub(crate) fn workflow_session_incarnation_fingerprint( session_id: &str, created_at: i64, diff --git a/src/tool_runtime/session_tools.rs b/src/tool_runtime/session_tools.rs index 54521994c..7606b7021 100644 --- a/src/tool_runtime/session_tools.rs +++ b/src/tool_runtime/session_tools.rs @@ -3,7 +3,7 @@ use super::session_context::{ absent_workflow_session_result, session_authority_denied_result, session_lifecycle_denied_result, session_message_error_result, session_project_mismatch_result, - workflow_session_authority_fingerprint, SessionProjectMismatch, + workflow_project_identity, workflow_session_authority_fingerprint, SessionProjectMismatch, }; use super::tool_inputs::SessionMode; use super::{sessions, RecoveryKind, ToolCall, ToolResult, ToolRuntime}; @@ -341,6 +341,16 @@ impl ToolRuntime { }), ); } + if resolved + .as_ref() + .is_some_and(|project| project.registration_incarnation.is_none()) + { + return ToolResult::err_with_output( + "project_registration_identity_unavailable", + json!({"error_kind": "project_registration_identity_unavailable", "state_changed": false, + "recovery": "upgrade the Runner and refresh the persisted Project inventory before starting a new Session"}), + ); + } let owner_authority_fingerprint = match workflow_session_authority_fingerprint(auth) { Ok(fingerprint) => fingerprint, Err(_) => { @@ -369,6 +379,13 @@ impl ToolRuntime { ), ) .with_owner_authority_fingerprint(Some(owner_authority_fingerprint)) + .with_project_identity(resolved.as_ref().and_then(|project| { + workflow_project_identity( + project.root_fingerprint.as_deref(), + &project.config.path, + project.registration_incarnation.as_deref(), + ) + })) .with_project_instructions(project_instructions.clone()) .with_execution_context(execution_context.unwrap_or_default()); if self.sessions.prepare_request().await.is_err() { @@ -539,6 +556,20 @@ impl ToolRuntime { // cannot escape cleanup by reserving after the initial scan. let persistent_shells_closed = self.close_persistent_shells_for_session(&session_id).await; + // The lifecycle and Shell evidence must survive a crash before + // claiming a successful close. A failed flush is indeterminate: + // the in-memory Session has already been closed. + if self.sessions.flush().await.is_err() { + return ToolResult::err_with_output( + "session_persistence_uncertain", + json!({ + "error_kind": "session_persistence_uncertain", + "session_id": session_id, + "state_changed": true, + "recovery": "retry close_session using the exact session_id after storage recovers", + }), + ); + } ToolResult::ok(json!({ "success": true, "session_id": outcome.summary.session_id, @@ -554,6 +585,60 @@ impl ToolRuntime { } } + /// Only historical Session read/close may outlive a Project registration. + /// A visible, online Runner without a *complete* authoritative Project + /// inventory cannot prove removal; a replaced/revoked Runner owner also + /// cannot inherit historical Session visibility. + async fn retired_project_session_history_authorized( + &self, + session_id: &str, + auth: Option<&AuthContext>, + ) -> bool { + let Some((Some(project), original_owner)) = + self.sessions.session_target_authority(session_id) + else { + return false; + }; + if workflow_session_authority_fingerprint(auth).ok().as_deref() + != Some(original_owner.as_str()) + { + return false; + } + let Some((client_id, project_id)) = project + .strip_prefix("agent:") + .and_then(|part| part.split_once(':')) + else { + return false; + }; + let access = crate::runner_http::runner_access_from_auth(auth); + if self + .runner_registry + .assert_runner_access(access.as_ref(), client_id) + .await + .is_err() + { + return false; + } + let Some(runner) = self + .runner_registry + .get_runner_view_for_auth(client_id, access.as_ref()) + .await + else { + return false; + }; + if !runner.connected + || runner.status != "online" + || runner + .project_inventory + .as_ref() + .is_none_or(|inventory| inventory.sync_state != "complete") + || runner.projects.iter().any(|item| item.id == project_id) + { + return false; + } + self.sessions.prepare_request().await.is_ok() + } + pub(crate) async fn authorize_session_target( &self, session_id: &str, @@ -570,16 +655,28 @@ impl ToolRuntime { )); }; if let Some(project) = project { - // Project authorization and immutable creation-time Session authority - // are independent checks. Local/dev has no credential project check, - // but still has the same canonical authority fence as every caller. + #[cfg(test)] + let synthetic_test_fixture = owner_authority_fingerprint + == sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT; + #[cfg(not(test))] + let synthetic_test_fixture = false; + let historical_only = matches!(tool_name, "read_session_summary" | "close_session"); let resolved = if let Some(auth) = auth { let resolved = match self .resolve_project_input_for_auth(&project, Some(auth)) .await { Ok(resolved) => resolved, - Err(err) => return Err(err.into_tool_result()), + Err(err) => { + if historical_only + && self + .retired_project_session_history_authorized(session_id, Some(auth)) + .await + { + return Ok(None); + } + return Err(err.into_tool_result()); + } }; if resolved.resolved_id != project { return Err(session_project_mismatch_result( @@ -595,16 +692,40 @@ impl ToolRuntime { } else { None }; + // Preserve Project concealment: an invisible Project must remain + // indistinguishable from an unknown one, even when a Session ID + // is supplied. A retired-Project fallback independently checks the + // original Session owner before accepting historical access. let caller_fingerprint = workflow_session_authority_fingerprint(auth) .map_err(|_| session_authority_denied_result(session_id, tool_name))?; - #[cfg(test)] - let synthetic_test_fixture = owner_authority_fingerprint - == sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT; - #[cfg(not(test))] - let synthetic_test_fixture = false; if !synthetic_test_fixture && owner_authority_fingerprint != caller_fingerprint { return Err(session_authority_denied_result(session_id, tool_name)); } + if let Some(resolved) = resolved.as_ref() { + let live_identity = workflow_project_identity( + resolved.root_fingerprint.as_deref(), + &resolved.config.path, + resolved.registration_incarnation.as_deref(), + ); + let recorded_identity = + self.sessions.session_project_identity(session_id).flatten(); + // Only a proven same registration may retain live or historical + // rights while registered. A missing legacy witness fails closed. + if !synthetic_test_fixture + && (live_identity.is_none() || recorded_identity != live_identity) + { + return Err(ToolResult::err_with_output( + "session_project_identity_changed: the original Project registration is no longer verified", + json!({ + "error_kind": "session_project_identity_changed", + "session_id": session_id, + "tool_name": tool_name, + "state_changed": false, + "recovery": "start a new Session for the registered Project; close the old Session only after its original Project is safely retired", + }), + ).with_recovery(RecoveryKind::FixInput)); + } + } self.sessions .prepare_request() .await diff --git a/src/tool_runtime/startup_brief.rs b/src/tool_runtime/startup_brief.rs index 788eb6a96..e82874f2d 100644 --- a/src/tool_runtime/startup_brief.rs +++ b/src/tool_runtime/startup_brief.rs @@ -1905,6 +1905,7 @@ mod tests { allow_patch: true, }, root_fingerprint: None, + registration_incarnation: None, knowledge_association: None, }; let instructions = instruction_snapshot(); diff --git a/src/tool_runtime/tests/code_mode_e2b.rs b/src/tool_runtime/tests/code_mode_e2b.rs index 11012ca55..d6e1bf242 100644 --- a/src/tool_runtime/tests/code_mode_e2b.rs +++ b/src/tool_runtime/tests/code_mode_e2b.rs @@ -94,19 +94,23 @@ fn commit_file(root: &Path, path: &str, content: &str) { git(root, &["commit", "-q", "-m", "fixture"]); } -fn start_coding_session_with_baseline( +async fn start_coding_session_with_baseline( runtime: &ToolRuntime, auth: &crate::auth::AuthContext, project: &str, baseline: String, instruction: &str, ) -> crate::tool_runtime::SessionSummary { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; let authority_fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(auth)).unwrap(); runtime .sessions .ensure_coding_session_with_git_baseline( CodingSessionRequest { + project_identity, project: project.to_string(), authority_fingerprint, resume_session_id: None, @@ -1142,7 +1146,8 @@ async fn e2b_nested_edit_drives_real_final_changes_baseline_to_full_final_worksp &project, baseline, "E2b Final Changes integration", - ); + ) + .await; // This non-Code-Mode workspace change proves Final Changes freezes the // complete baseline→final workspace, not merely the nested edit provenance. @@ -1269,7 +1274,8 @@ async fn e2b_outer_only_noop_and_prestart_failure_do_not_create_final_changes_el &project, baseline.clone(), "outer-only E2b", - ); + ) + .await; let read = spawn_e2b_call( &runtime, &project, @@ -1295,7 +1301,8 @@ async fn e2b_outer_only_noop_and_prestart_failure_do_not_create_final_changes_el &project, baseline.clone(), "no-op E2b", - ); + ) + .await; let noop_task = spawn_e2b_call( &runtime, &project, @@ -1324,7 +1331,8 @@ async fn e2b_outer_only_noop_and_prestart_failure_do_not_create_final_changes_el &project, baseline, "prestart failure E2b", - ); + ) + .await; let prestart_result = spawn_e2b_call( &runtime, &project, diff --git a/src/tool_runtime/tests/coding_agent_context.rs b/src/tool_runtime/tests/coding_agent_context.rs index 145a282c5..0b97016d2 100644 --- a/src/tool_runtime/tests/coding_agent_context.rs +++ b/src/tool_runtime/tests/coding_agent_context.rs @@ -13,7 +13,14 @@ use webcodex_core::coding_agent::{ CodingAgentResponsePayload, CodingAgentRunSnapshot, CodingAgentRunState, }; -fn seed(runtime: &ToolRuntime, project: Option<&str>, auth: &AuthContext) -> String { +async fn seed(runtime: &ToolRuntime, project: Option<&str>, auth: &AuthContext) -> String { + let project_identity = match project { + Some(project) => { + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await + } + None => None, + }; let id = runtime .sessions .start_session_with_options( @@ -25,7 +32,8 @@ fn seed(runtime: &ToolRuntime, project: Option<&str>, auth: &AuthContext) -> Str ) .with_owner_authority_fingerprint(Some( workflow_session_authority_fingerprint(Some(auth)).unwrap(), - )), + )) + .with_project_identity(project_identity), ) .unwrap() .session_id; @@ -72,7 +80,7 @@ async fn coding_agent_context_recovers_exact_closed_history_without_resuming_or_ )); let project = register_runner_project_at_path(&runtime, "context", "demo", root.path()).await; let auth = bootstrap_auth_context(); - let id = seed(&runtime, Some(&project), &auth); + let id = seed(&runtime, Some(&project), &auth).await; runtime.sessions.close_session(&id).unwrap(); let listed = runtime .dispatch_with_auth( @@ -132,12 +140,13 @@ async fn coding_agent_context_rejects_foreign_owner_wrong_project_missing_scope_ let project = register_runner_project_at_path(&runtime, "context", "demo", root.path()).await; let other = register_runner_project_at_path(&runtime, "other", "demo", root.path()).await; let auth = bootstrap_auth_context(); - let id = seed(&runtime, Some(&project), &auth); + let id = seed(&runtime, Some(&project), &auth).await; let foreign = seed( &runtime, Some(&project), &shared_key_auth_context("different-owner"), - ); + ) + .await; for (source, target) in [(&foreign, &project), (&id, &other)] { let error = runtime .coding_agent_context_instruction(target, source, "Review", Some(&auth)) @@ -149,7 +158,7 @@ async fn coding_agent_context_rejects_foreign_owner_wrong_project_missing_scope_ .to_string() .contains("existing database schema")); } - let unscoped = seed(&runtime, None, &auth); + let unscoped = seed(&runtime, None, &auth).await; let error = runtime .coding_agent_context_instruction(&project, &unscoped, "Review", Some(&auth)) .await @@ -200,7 +209,7 @@ async fn coding_agent_context_is_frozen_in_dispatched_intent_and_changed_retry_c ) .await; let auth = bootstrap_auth_context(); - let id = seed(&runtime, Some(&project), &auth); + let id = seed(&runtime, Some(&project), &auth).await; let call = || ToolCall::CodingAgentStart { project: project.clone(), provider_id: "review".into(), @@ -297,12 +306,12 @@ async fn coding_agent_context_closed_selectors_survive_cache_capacity_without_di ) .await; let auth = bootstrap_auth_context(); - let closed = seed(&runtime, Some(&project), &auth); + let closed = seed(&runtime, Some(&project), &auth).await; let session_ref = runtime .session_reference_for_id(&closed, Some(&auth)) .expect("live session issues a ref"); runtime.sessions.close_session(&closed).unwrap(); - let retained = seed(&runtime, Some(&project), &auth); + let retained = seed(&runtime, Some(&project), &auth).await; runtime.sessions.close_session(&retained).unwrap(); assert!(runtime.sessions.contains_session(&closed)); assert!(runtime.sessions.contains_session(&retained)); diff --git a/src/tool_runtime/tests/collaboration.rs b/src/tool_runtime/tests/collaboration.rs index d11ebefca..9b60a8fc2 100644 --- a/src/tool_runtime/tests/collaboration.rs +++ b/src/tool_runtime/tests/collaboration.rs @@ -83,12 +83,15 @@ fn tool_names(runtime: &ToolRuntime, session_id: &str) -> Vec { .collect() } -fn start_authorized_project_session( +async fn start_authorized_project_session( runtime: &ToolRuntime, project: &str, title: Option<&str>, auth: &AuthContext, ) -> sessions::SessionSummary { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; let fingerprint = super::super::session_context::workflow_session_authority_fingerprint(Some(auth)) .expect("test authority must have a stable identity"); @@ -101,7 +104,8 @@ fn start_authorized_project_session( super::super::SessionMode::Normal, sessions::SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(fingerprint)), + .with_owner_authority_fingerprint(Some(fingerprint)) + .with_project_identity(project_identity), ) .unwrap() } @@ -555,19 +559,22 @@ async fn observe_session_messages_collaboration_recorder_target_scope_fences() { "agent:workstation:my-repo", Some("observation coordinator"), &auth, - ); + ) + .await; let same_project_worker = start_authorized_project_session( &runtime, "agent:workstation:my-repo", Some("same-project worker"), &auth, - ); + ) + .await; let other_project_worker = start_authorized_project_session( &runtime, "agent:workstation:other-repo", Some("other-project worker"), &auth, - ); + ) + .await; let allowed = call_with_recorder( &runtime, @@ -818,8 +825,9 @@ async fn collaboration_two_sessions_keep_execution_history_and_explicit_provenan let project = agent_test_project_id(client_id); let auth = auth_context(None, true); let coordinator = - start_authorized_project_session(&runtime, &project, Some("coordinator C"), &auth); - let worker = start_authorized_project_session(&runtime, &project, Some("worker W"), &auth); + start_authorized_project_session(&runtime, &project, Some("coordinator C"), &auth).await; + let worker = + start_authorized_project_session(&runtime, &project, Some("worker W"), &auth).await; assert_ne!(coordinator.session_id, worker.session_id); let coordinator_window = ClientWindow::for_test("collaboration-coordinator-window"); @@ -1076,7 +1084,7 @@ async fn collaboration_completion_without_recording_session_has_null_author_even register_agent(&runtime, client_id, None, RunnerCapabilities::default()).await; let project = agent_test_project_id(client_id); let auth = auth_context(None, true); - let coordinator = start_authorized_project_session(&runtime, &project, None, &auth); + let coordinator = start_authorized_project_session(&runtime, &project, None, &auth).await; let todo = runtime .sessions .post_message(PostSessionMessageInput { @@ -1123,9 +1131,9 @@ async fn collaboration_explicit_recording_session_is_completion_author() { let project = agent_test_project_id(client_id); let auth = auth_context(None, true); let coordinator = - start_authorized_project_session(&runtime, &project, Some("coordinator"), &auth); + start_authorized_project_session(&runtime, &project, Some("coordinator"), &auth).await; let worker_recording = - start_authorized_project_session(&runtime, &project, Some("worker W1"), &auth); + start_authorized_project_session(&runtime, &project, Some("worker W1"), &auth).await; let todo = runtime .sessions .post_message(PostSessionMessageInput { @@ -1191,13 +1199,15 @@ async fn collaboration_cross_project_recorder_fails_closed_before_completion() { let runtime = runtime_with_resolver_projects().await; let auth = auth_context(None, true); let coordinator = - start_authorized_project_session(&runtime, "agent:workstation:my-repo", Some("C"), &auth); + start_authorized_project_session(&runtime, "agent:workstation:my-repo", Some("C"), &auth) + .await; let worker = start_authorized_project_session( &runtime, "agent:workstation:other-repo", Some("W"), &auth, - ); + ) + .await; let todo = runtime .sessions .post_message(PostSessionMessageInput { @@ -1460,7 +1470,8 @@ async fn collaboration_mixed_project_scope_fails_closed_in_both_directions() { "agent:workstation:my-repo", Some("scoped coordinator"), &auth, - ); + ) + .await; let unscoped_worker = call_with_recorder( &runtime, "start_session", @@ -1558,7 +1569,8 @@ async fn collaboration_mixed_project_scope_fails_closed_in_both_directions() { "agent:workstation:my-repo", Some("scoped worker"), &auth, - ); + ) + .await; let unscoped_todo = runtime .sessions .post_message(PostSessionMessageInput { @@ -2103,7 +2115,7 @@ async fn collaboration_foreign_owner_cannot_read_or_complete_known_session_and_t ) .await; let project = "agent:alice-host:private".to_string(); - let coordinator = start_authorized_project_session(&runtime, &project, None, &alice); + let coordinator = start_authorized_project_session(&runtime, &project, None, &alice).await; let todo = runtime .sessions .post_message(PostSessionMessageInput { diff --git a/src/tool_runtime/tests/continuation_feedback.rs b/src/tool_runtime/tests/continuation_feedback.rs index 3e09a8695..6b2d9fa69 100644 --- a/src/tool_runtime/tests/continuation_feedback.rs +++ b/src/tool_runtime/tests/continuation_feedback.rs @@ -829,6 +829,7 @@ fn add_instruction(runtime: &ToolRuntime, session_id: &str, instruction: &str, m runtime .sessions .ensure_coding_session(sessions::CodingSessionRequest { + project_identity: None, project: "test-project".to_string(), authority_fingerprint: sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT .to_string(), @@ -936,6 +937,7 @@ fn add_instruction_for( runtime .sessions .ensure_coding_session(sessions::CodingSessionRequest { + project_identity: None, project: project.to_string(), authority_fingerprint: sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT .to_string(), diff --git a/src/tool_runtime/tests/control_sidecars.rs b/src/tool_runtime/tests/control_sidecars.rs index d7f42fd4b..8dcde8cbe 100644 --- a/src/tool_runtime/tests/control_sidecars.rs +++ b/src/tool_runtime/tests/control_sidecars.rs @@ -976,6 +976,12 @@ async fn control_sidecars_real_finish_dispatch_nonblocking_and_blocking() { .await; let owner = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&f.auth)).unwrap(); + let project_identity = crate::test_support::registered_session_project_identity( + &f.runtime, + &project, + Some(&f.auth), + ) + .await; let session = f .runtime .sessions @@ -986,7 +992,8 @@ async fn control_sidecars_real_finish_dispatch_nonblocking_and_blocking() { crate::tool_runtime::SessionMode::Normal, SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(owner)), + .with_owner_authority_fingerprint(Some(owner)) + .with_project_identity(project_identity), ) .unwrap() .session_id; diff --git a/src/tool_runtime/tests/external_observations.rs b/src/tool_runtime/tests/external_observations.rs index 183622570..4e242fb8d 100644 --- a/src/tool_runtime/tests/external_observations.rs +++ b/src/tool_runtime/tests/external_observations.rs @@ -30,6 +30,9 @@ async fn external_observations_runtime_scope_replay_unknown_and_readback() { ); opts.owner_authority_fingerprint = Some(workflow_session_authority_fingerprint(Some(&auth)).unwrap()); + opts.project_identity = + crate::test_support::registered_session_project_identity(&runtime, &project, Some(&auth)) + .await; let session = runtime .sessions .start_session_with_options(opts) @@ -202,6 +205,9 @@ async fn external_reports_reach_default_and_diagnostic_handoff_without_native_pr ); opts.owner_authority_fingerprint = Some(workflow_session_authority_fingerprint(Some(&auth)).unwrap()); + opts.project_identity = + crate::test_support::registered_session_project_identity(&runtime, &project, Some(&auth)) + .await; let session = runtime .sessions .start_session_with_options(opts) diff --git a/src/tool_runtime/tests/goal_workflow.rs b/src/tool_runtime/tests/goal_workflow.rs index 123d84848..371eae132 100644 --- a/src/tool_runtime/tests/goal_workflow.rs +++ b/src/tool_runtime/tests/goal_workflow.rs @@ -37,6 +37,7 @@ impl Workflow { .await; let session_id = start_goal_activity_session(&runtime, &auth, &project, "Current Goal workflow") + .await .session_id; let agent = runtime.create_agent_identity( Some(&auth), @@ -1028,6 +1029,7 @@ async fn goal_workflow_exact_business_session_evidence_covers_only_its_own_recor &different.project, "Different business Session", ) + .await .session_id; let different_work = T0 + 3_000; different.recorder_gap_work(different_work, Some(&other_session), None); @@ -1228,7 +1230,8 @@ async fn goal_workflow_revoked_correlated_project_is_partial_even_with_older_wor &fixture.auth, &other_project, "Earlier explicitly correlated work", - ); + ) + .await; link_goal_activity_session( &fixture.runtime, &fixture.auth, @@ -1318,7 +1321,8 @@ async fn goal_workflow_requires_current_unambiguous_active_session_not_historica &fixture.auth, &fixture.project, "Different current work", - ); + ) + .await; record_goal_window_event( &fixture.db, &fixture.auth, @@ -1892,7 +1896,8 @@ async fn goal_workflow_checkpoint_card_and_closeout_are_sparse_canonical_and_own &fixture.auth, &fixture.project, "Same Project is not Goal correlation", - ); + ) + .await; assert!(fixture .runtime .active_goal_context_for_session(Some(&fixture.auth), &other.session_id) @@ -2287,7 +2292,8 @@ async fn goal_workflow_old_work_imported_through_new_correlation_does_not_manufa &fixture.auth, &fixture.project, "Already-existing prior work", - ); + ) + .await; record_goal_window_event( &fixture.db, &fixture.auth, diff --git a/src/tool_runtime/tests/goals.rs b/src/tool_runtime/tests/goals.rs index 540a0fd42..d2a244d48 100644 --- a/src/tool_runtime/tests/goals.rs +++ b/src/tool_runtime/tests/goals.rs @@ -95,12 +95,15 @@ async fn register_goal_activity_project( crate::tool_runtime::runner_project_runtime_id(client_id, project_id) } -fn start_goal_activity_session( +async fn start_goal_activity_session( runtime: &ToolRuntime, auth: &crate::auth::AuthContext, project: &str, title: &str, ) -> crate::tool_runtime::sessions::SessionSummary { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; let fingerprint = super::super::session_context::workflow_session_authority_fingerprint(Some(auth)) .expect("stable Goal activity test authority"); @@ -113,7 +116,8 @@ fn start_goal_activity_session( crate::tool_runtime::SessionMode::Normal, crate::tool_runtime::sessions::SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(fingerprint)), + .with_owner_authority_fingerprint(Some(fingerprint)) + .with_project_identity(project_identity), ) .unwrap() } @@ -854,7 +858,8 @@ async fn prepare_goal_workflow_reauthorizes_session_project_controller_and_stays temp.path(), ) .await; - let session = start_goal_activity_session(&runtime, &owner, &project, "Prepare Goal Session"); + let session = + start_goal_activity_session(&runtime, &owner, &project, "Prepare Goal Session").await; let controller = runtime.create_agent_identity( Some(&owner), @@ -1385,7 +1390,7 @@ async fn goal_activity_tracks_window_wide_work_and_separates_seen_from_meaningfu temp.path(), ) .await; - let session = start_goal_activity_session(&runtime, &auth, &project_a, "Goal Session A"); + let session = start_goal_activity_session(&runtime, &auth, &project_a, "Goal Session A").await; let goal_id = create_goal(&runtime, Some(&auth), "goal-window-wide-create"); link_goal_activity_session( &runtime, @@ -1473,7 +1478,7 @@ async fn goal_activity_is_unobserved_without_windows_and_inflight_work_prevents_ temp.path(), ) .await; - let session = start_goal_activity_session(&runtime, &auth, &project, "Goal inflight"); + let session = start_goal_activity_session(&runtime, &auth, &project, "Goal inflight").await; let goal_id = create_goal(&runtime, Some(&auth), "goal-inflight-create"); link_goal_activity_session( &runtime, @@ -1576,7 +1581,8 @@ async fn goal_activity_respects_principal_project_visibility_and_runtime_read_sc temp.path(), ) .await; - let session = start_goal_activity_session(&runtime, &alice, &visible, "Visible Goal Session"); + let session = + start_goal_activity_session(&runtime, &alice, &visible, "Visible Goal Session").await; let goal_id = create_goal(&runtime, Some(&alice), "goal-visibility-create"); link_goal_activity_session( &runtime, @@ -1663,7 +1669,8 @@ async fn goal_activity_bounded_partial_window_scan_never_manufactures_attention( temp.path(), ) .await; - let session = start_goal_activity_session(&runtime, &auth, &project, "Partial Goal Session"); + let session = + start_goal_activity_session(&runtime, &auth, &project, "Partial Goal Session").await; let goal_id = create_goal(&runtime, Some(&auth), "goal-partial-create"); link_goal_activity_session( &runtime, diff --git a/src/tool_runtime/tests/handoff.rs b/src/tool_runtime/tests/handoff.rs index a4fdc8411..818b23256 100644 --- a/src/tool_runtime/tests/handoff.rs +++ b/src/tool_runtime/tests/handoff.rs @@ -1299,6 +1299,7 @@ async fn closeout_boundary_gap_keeps_historical_validation_visible_without_curre runtime .sessions .ensure_coding_session(crate::tool_runtime::sessions::CodingSessionRequest { + project_identity: None, project: project.clone(), authority_fingerprint: crate::tool_runtime::sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT diff --git a/src/tool_runtime/tests/handoff/continuity.rs b/src/tool_runtime/tests/handoff/continuity.rs index 63b3fe35e..d2630aa82 100644 --- a/src/tool_runtime/tests/handoff/continuity.rs +++ b/src/tool_runtime/tests/handoff/continuity.rs @@ -79,6 +79,7 @@ async fn handoff_reread_recovers_corrected_task_without_reusing_validation_or_re runtime .sessions .ensure_coding_session(sessions::CodingSessionRequest { + project_identity: None, project: project.clone(), authority_fingerprint: sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT .to_string(), diff --git a/src/tool_runtime/tests/handoff_brief.rs b/src/tool_runtime/tests/handoff_brief.rs index 7de8f6751..84c9b01ca 100644 --- a/src/tool_runtime/tests/handoff_brief.rs +++ b/src/tool_runtime/tests/handoff_brief.rs @@ -38,6 +38,7 @@ fn add_instruction_for_project( ) { store .ensure_coding_session(CodingSessionRequest { + project_identity: None, project: project.to_string(), authority_fingerprint: crate::tool_runtime::sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT diff --git a/src/tool_runtime/tests/memory.rs b/src/tool_runtime/tests/memory.rs index dc30868bb..73679c77f 100644 --- a/src/tool_runtime/tests/memory.rs +++ b/src/tool_runtime/tests/memory.rs @@ -24,6 +24,7 @@ fn resolved(id: &str, client: &str, root: &str) -> ResolvedProject { allow_patch: true, }, root_fingerprint: None, + registration_incarnation: None, knowledge_association: None, } } diff --git a/src/tool_runtime/tests/mod.rs b/src/tool_runtime/tests/mod.rs index 7beda00c9..ada006fd5 100644 --- a/src/tool_runtime/tests/mod.rs +++ b/src/tool_runtime/tests/mod.rs @@ -82,6 +82,7 @@ mod sessions_git; mod sessions_guards; mod sessions_instructions; mod sessions_resolver; +mod sessions_retired_projects; mod skills; mod specialized_dispatch; mod startup_brief; diff --git a/src/tool_runtime/tests/read_files.rs b/src/tool_runtime/tests/read_files.rs index c91680ce3..f4dd030f3 100644 --- a/src/tool_runtime/tests/read_files.rs +++ b/src/tool_runtime/tests/read_files.rs @@ -2522,6 +2522,7 @@ async fn read_files_ignores_context_ack_and_preserves_bounded_attention() { runtime .sessions .ensure_coding_session(crate::tool_runtime::sessions::CodingSessionRequest { + project_identity: None, project: project.clone(), authority_fingerprint: crate::tool_runtime::sessions::TEST_ONLY_PROJECT_SESSION_AUTHORITY_FINGERPRINT diff --git a/src/tool_runtime/tests/reconnect.rs b/src/tool_runtime/tests/reconnect.rs index 138d05f2b..7530e64a8 100644 --- a/src/tool_runtime/tests/reconnect.rs +++ b/src/tool_runtime/tests/reconnect.rs @@ -432,15 +432,42 @@ async fn canonical_project_session_explicit_resume_survives_restart() { persisted_session_authority_fingerprint(&ledger, &session_id).as_deref(), Some(expected_fingerprint.as_str()) ); + let original_registration = runtime1 + .runner_registry + .get_runner_view("canonical-restart-agent") + .await + .unwrap() + .projects + .into_iter() + .find(|project| project.id == "proj") + .unwrap(); drop(runtime1); let runtime2 = ToolRuntime::new_for_tests().with_session_ledger(&ledger); - register_runner_project_at_path_with_auth( - &runtime2, + runtime2 + .runner_registry + .register_with_auth( + crate::test_support::runner_registration( + "canonical-restart-agent", + "inst", + RunnerCapabilities { + shell: true, + git: true, + file_read: true, + file_write: true, + internal_posix_script: true, + ..Default::default() + }, + ), + Some(&crate::test_support::runner_access(&alice)), + ) + .await + .unwrap(); + crate::test_support::apply_project_inventory_snapshot( + &runtime2.runner_registry, "canonical-restart-agent", - "proj", - &project_root, - &alice, + "inst", + vec![original_registration], ) .await; assert_eq!(runtime2.sessions.status().restored_sessions, 1); diff --git a/src/tool_runtime/tests/resource_references.rs b/src/tool_runtime/tests/resource_references.rs index 17e80131d..275effaa7 100644 --- a/src/tool_runtime/tests/resource_references.rs +++ b/src/tool_runtime/tests/resource_references.rs @@ -67,7 +67,10 @@ async fn register(runtime: &ToolRuntime, name: &str, root: char) -> String { format!("agent:resources:{name}") } -fn session(runtime: &ToolRuntime, project: &str, auth: &AuthContext) -> String { +async fn session(runtime: &ToolRuntime, project: &str, auth: &AuthContext) -> String { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; runtime .sessions .start_session_with_options( @@ -79,7 +82,8 @@ fn session(runtime: &ToolRuntime, project: &str, auth: &AuthContext) -> String { ) .with_owner_authority_fingerprint(Some( workflow_session_authority_fingerprint(Some(auth)).unwrap(), - )), + )) + .with_project_identity(project_identity), ) .unwrap() .session_id @@ -247,7 +251,7 @@ async fn artifact_discovery_uses_exact_session_latest_manifest_and_historical_pr let (_temp, runtime) = fixture(); let project = register(&runtime, "demo", '1').await; let auth = auth_context(None, true); - let selected = session(&runtime, &project, &auth); + let selected = session(&runtime, &project, &auth).await; let output = |path: &str| json!({"task_outputs":{"items":[{"path":path,"status":"verified","file_bytes":7,"sha256":"a".repeat(64),"mime_type":"text/plain"}],"verified_count":1,"missing_count":0,"unavailable_count":0,"observed_at":123}}); finish(&runtime, &project, &selected, output("old.txt")); finish(&runtime, &project, &selected, output("latest.txt")); @@ -270,7 +274,7 @@ async fn artifact_discovery_uses_exact_session_latest_manifest_and_historical_pr !uri.contains(&"a".repeat(64)), "historical SHA must not pin current file identity" ); - let other_project_session = session(&runtime, "agent:resources:other", &auth); + let other_project_session = session(&runtime, "agent:resources:other", &auth).await; let mismatch = search( &runtime, WebcodexResourceKind::Artifact, @@ -295,7 +299,8 @@ async fn artifact_discovery_uses_exact_session_latest_manifest_and_historical_pr &runtime, &project, &reader("other", &[SCOPE_PROJECT_READ, SCOPE_RUNTIME_READ]), - ); + ) + .await; assert!( !search( &runtime, diff --git a/src/tool_runtime/tests/session_discovery.rs b/src/tool_runtime/tests/session_discovery.rs index c21b2614f..be77c2c7f 100644 --- a/src/tool_runtime/tests/session_discovery.rs +++ b/src/tool_runtime/tests/session_discovery.rs @@ -15,12 +15,15 @@ fn discovery_call(project: &str, offset: usize, limit: usize) -> ToolCall { } } -fn seed( +async fn seed( runtime: &ToolRuntime, project: &str, auth: &crate::auth::AuthContext, title: &str, ) -> String { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; runtime .sessions .start_session_with_options( @@ -32,7 +35,8 @@ fn seed( ) .with_owner_authority_fingerprint(Some( workflow_session_authority_fingerprint(Some(auth)).unwrap(), - )), + )) + .with_project_identity(project_identity), ) .unwrap() .session_id @@ -45,7 +49,7 @@ async fn session_discovery_requires_project_visibility_and_scopes() { let project = register_runner_project_at_path(&runtime, "discovery-owner", "demo", root.path()).await; let bootstrap = auth_context(None, true); - seed(&runtime, &project, &bootstrap, "private title"); + seed(&runtime, &project, &bootstrap, "private title").await; let mut unprivileged = auth_context(Some("outsider"), false); unprivileged.scopes = vec![ SCOPE_RUNTIME_READ.to_string(), @@ -83,10 +87,10 @@ async fn session_discovery_returns_exact_refs_and_does_not_resume() { let auth = auth_context(None, true); let foreign = auth_context(Some("foreign"), false); for _ in 0..22 { - seed(&runtime, &project, &foreign, "FOREIGN TITLE"); + seed(&runtime, &project, &foreign, "FOREIGN TITLE").await; } - let active = seed(&runtime, &project, &auth, "existing work"); - let closed = seed(&runtime, &project, &auth, "finished work"); + let active = seed(&runtime, &project, &auth, "existing work").await; + let closed = seed(&runtime, &project, &auth, "finished work").await; runtime.sessions.close_session(&closed).unwrap(); let count_before = runtime.sessions.status().retained_sessions; let first = runtime @@ -141,7 +145,7 @@ async fn session_discovery_returns_exact_refs_and_does_not_resume() { .await; assert_eq!(historical.output["sessions"][0]["session_id"], closed); for _ in 0..25 { - seed(&runtime, &project, &auth, &"界".repeat(1000)); + seed(&runtime, &project, &auth, &"界".repeat(1000)).await; } let capped = runtime .dispatch_with_auth(discovery_call(&project, 0, usize::MAX), Some(&auth)) diff --git a/src/tool_runtime/tests/session_shells.rs b/src/tool_runtime/tests/session_shells.rs index 105bac216..a291685f1 100644 --- a/src/tool_runtime/tests/session_shells.rs +++ b/src/tool_runtime/tests/session_shells.rs @@ -26,6 +26,7 @@ async fn setup( ..Default::default() }, vec![RunnerProjectSummary { + registration_incarnation: None, id: PROJECT_ID.to_string(), name: Some(PROJECT_ID.to_string()), path: root.to_string_lossy().to_string(), @@ -1029,6 +1030,7 @@ async fn setup_ssh_with_capabilities( ..Default::default() }, vec![RunnerProjectSummary { + registration_incarnation: None, id: PROJECT_ID.to_string(), name: Some(PROJECT_ID.to_string()), path: root.to_string_lossy().to_string(), diff --git a/src/tool_runtime/tests/sessions_guards.rs b/src/tool_runtime/tests/sessions_guards.rs index cf75c0bee..f619a5bed 100644 --- a/src/tool_runtime/tests/sessions_guards.rs +++ b/src/tool_runtime/tests/sessions_guards.rs @@ -327,6 +327,9 @@ async fn closed_recording_session_remains_provenance_only_for_business_write() { let auth = auth_context(None, true); let authority = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&auth)) .expect("closed recorder auth should have stable authority"); + let project_identity = + crate::test_support::registered_session_project_identity(&runtime, &project, Some(&auth)) + .await; let recorder = runtime .sessions .start_session_with_options( @@ -336,7 +339,8 @@ async fn closed_recording_session_remains_provenance_only_for_business_write() { crate::tool_runtime::SessionMode::Normal, crate::tool_runtime::SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(authority)), + .with_owner_authority_fingerprint(Some(authority)) + .with_project_identity(project_identity), ) .unwrap(); let recorder_ref = runtime diff --git a/src/tool_runtime/tests/sessions_resolver.rs b/src/tool_runtime/tests/sessions_resolver.rs index 6bd77d114..aac3e1ab2 100644 --- a/src/tool_runtime/tests/sessions_resolver.rs +++ b/src/tool_runtime/tests/sessions_resolver.rs @@ -4,6 +4,75 @@ use super::super::*; use super::support::*; use crate::runner_protocol::RunnerResultRequest; +#[tokio::test] +async fn project_unregister_must_not_strand_owned_session_closeout() { + let runtime = runtime_with_resolver_projects().await; + let owner = auth_context(None, true); + let started = runtime + .dispatch_with_auth( + ToolCall::StartSession { + project: Some("agent:workstation:my-repo".into()), + title: Some("retired project fixture".into()), + mode: SessionMode::Normal, + deny_write_tools: false, + deny_shell_tools: false, + execution_context: None, + }, + Some(&owner), + ) + .await; + assert!(started.success, "{:?}", started.error); + let session_id = started.output["session_id"].as_str().unwrap().to_string(); + let runner_instance = runtime + .runner_registry + .get_runner_view("workstation") + .await + .unwrap() + .runner_instance_id; + assert!(runtime + .runner_registry + .remove_runner_project_for_instance("workstation", &runner_instance, "my-repo") + .await + .unwrap()); + + let attacker = shared_key_auth_context("different-session-owner"); + let denied = runtime + .dispatch_with_auth( + ToolCall::SessionSummary { + session_id: session_id.clone(), + limit: Some(10), + }, + Some(&attacker), + ) + .await; + assert!( + !denied.success, + "another authority must not inherit Session ownership" + ); + let history = runtime + .dispatch_with_auth( + ToolCall::SessionSummary { + session_id: session_id.clone(), + limit: Some(10), + }, + Some(&owner), + ) + .await; + assert!(history.success, "{:?}", history.error); + assert_eq!(history.output["session_id"], session_id); + + let closed = runtime + .dispatch_with_auth( + ToolCall::CloseSession { + session_id: session_id.clone(), + }, + Some(&owner), + ) + .await; + assert!(closed.success, "{:?}", closed.error); + assert_eq!(closed.output["lifecycle"], "closed"); +} + #[tokio::test] async fn project_resolver_resolves_full_id() { let runtime = runtime_with_resolver_projects().await; diff --git a/src/tool_runtime/tests/sessions_retired_projects.rs b/src/tool_runtime/tests/sessions_retired_projects.rs new file mode 100644 index 000000000..e33423db3 --- /dev/null +++ b/src/tool_runtime/tests/sessions_retired_projects.rs @@ -0,0 +1,558 @@ +//! Owner, inventory, identity, and durability fences for retired Project history. +use super::super::kernel::{ + HostFileImportTrust, ToolCallContext, ToolCallOutcome, ToolCallRequest, ToolTransport, +}; +use super::super::*; +use super::support::*; +use crate::auth::AuthContext; +use crate::runner_protocol::{RunnerCapabilities, RunnerRegisterRequest}; +use serde_json::json; + +const CLIENT: &str = "retired-project"; +const PROJECT: &str = "agent:retired-project:repo"; + +fn owner(admin: bool) -> AuthContext { + let mut auth = auth_context(Some("alice"), false); + auth.scopes = if admin { + vec!["admin".into()] + } else { + vec![ + "runtime:read".into(), + "session:collaborate".into(), + "project:read".into(), + "project:write".into(), + "job:run".into(), + ] + }; + auth +} + +async fn register(runtime: &ToolRuntime, username: &str, root: Option<&str>) { + register_agent_projects( + runtime, + CLIENT, + Some(username), + RunnerCapabilities::default(), + root.map(|root| registered_project("repo", root)) + .into_iter() + .collect(), + ) + .await; +} + +async fn call( + runtime: &ToolRuntime, + auth: Option<&AuthContext>, + tool: &str, + arguments: serde_json::Value, + recorder: Option<&str>, +) -> ToolCallOutcome { + runtime + .call_tool_with_context( + ToolCallRequest { + tool_name: tool.into(), + arguments, + }, + ToolCallContext { + transport: ToolTransport::Api, + session_id: recorder, + auth, + window: None, + record_oauth_scope_denials: true, + host_file_import_trust: HostFileImportTrust::Untrusted, + }, + ) + .await +} + +async fn start(runtime: &ToolRuntime, auth: &AuthContext) -> String { + let result = call( + runtime, + Some(auth), + "start_session", + json!({"project":PROJECT}), + None, + ) + .await; + assert!(result.success, "{result:?}"); + result.result.unwrap().output["session_id"] + .as_str() + .unwrap() + .into() +} + +async fn unregister(runtime: &ToolRuntime) { + assert!(runtime + .runner_registry + .remove_runner_project_for_instance(CLIENT, &format!("inst-{CLIENT}"), "repo",) + .await + .unwrap()); +} + +#[tokio::test] +async fn runtime_unregister_receipt_preserves_authorized_historical_close() { + let runtime = test_runtime(); + let auth = owner(false); + let revision = format!("sha256:{}", "a".repeat(64)); + let mut project = registered_project("repo", "/original-root"); + project.revision = Some(revision.clone()); + register_agent_projects( + &runtime, + CLIENT, + Some("alice"), + RunnerCapabilities { + project_lifecycle: true, + ..Default::default() + }, + vec![project], + ) + .await; + let id = start(&runtime, &auth).await; + + // Drive the real Server unregister service through a Runner request/receipt, + // rather than deleting the Registry entry directly as other unit tests do. + let request_runtime = runtime.clone(); + let request_auth = auth.clone(); + let unregister_task = tokio::spawn(async move { + crate::admin_project_lifecycle::unregister_project_runtime( + &request_runtime, + Some(&request_auth), + PROJECT, + &revision, + ) + .await + }); + let request = + wait_for_runner_request_for_instance(&runtime, CLIENT, &format!("inst-{CLIENT}")).await; + assert_eq!(request.kind, "project_lifecycle_unregister"); + let webcodex_core::runner_operation::RunnerOperation::Project(operation) = + request.decode_operation().unwrap() + else { + panic!("expected a typed Project lifecycle operation"); + }; + let request_payload: serde_json::Value = serde_json::from_str(&operation.payload).unwrap(); + assert_eq!(request_payload["project_id"], "repo"); + complete_patch_agent_request_for_instance( + &runtime, + CLIENT, + &format!("inst-{CLIENT}"), + &request.request_id, + 0, + &json!({"outcome":"unregistered","changed":true,"revision":null}).to_string(), + "", + ) + .await; + let reply = unregister_task.await.unwrap(); + assert_eq!(reply.status, 200, "{reply:?}"); + assert_eq!(reply.body["outcome"], "unregistered"); + + let history = call( + &runtime, + Some(&auth), + "read_session_summary", + json!({"session_id":id}), + None, + ) + .await; + assert!( + history.success, + "historical read should survive unregister: {history:?}" + ); + + let execution = call( + &runtime, + Some(&auth), + "update_session_context", + json!({"project":PROJECT,"session_id":id,"execution_context":{"default_cwd":"src"}}), + None, + ) + .await; + assert!( + !execution.success, + "retired Project must not admit live mutation" + ); + + let close = call( + &runtime, + Some(&auth), + "close_session", + json!({"session_id":id}), + None, + ) + .await; + assert!(close.success, "authorized historical close: {close:?}"); + assert_eq!(close.result.unwrap().output["already_closed"], false); + assert_eq!( + runtime.sessions.lifecycle_state(&id), + Some(sessions::SessionLifecycle::Closed) + ); +} + +#[tokio::test] +async fn retired_project_history_requires_current_runner_owner_even_for_admin() { + for admin in [true, false] { + let runtime = test_runtime(); + let auth = owner(admin); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + register(&runtime, "bob", None).await; + for tool in ["read_session_summary", "close_session"] { + let result = call(&runtime, Some(&auth), tool, json!({"session_id":id}), None).await; + assert!( + !result.success, + "admin visibility must not bypass revoked Runner ownership: {result:?}" + ); + } + assert_eq!( + runtime.sessions.lifecycle_state(&id), + Some(sessions::SessionLifecycle::Active) + ); + } +} + +#[tokio::test] +async fn retired_project_history_supports_scoped_owner_and_kernel_recorder() { + for admin in [true, false] { + let runtime = test_runtime(); + let auth = owner(admin); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + for recorder in [None, Some(id.as_str())] { + let result = call( + &runtime, + Some(&auth), + "read_session_summary", + json!({"session_id":id}), + recorder, + ) + .await; + assert!( + result.success, + "owner history (admin={admin}, recorder={recorder:?}): {result:?}" + ); + } + for already_closed in [false, true] { + let result = call( + &runtime, + Some(&auth), + "close_session", + json!({"session_id":id}), + Some(&id), + ) + .await; + assert!(result.success, "{result:?}"); + assert_eq!( + result.result.unwrap().output["already_closed"], + already_closed + ); + } + let result = call( + &runtime, + Some(&auth), + "read_session_summary", + json!({"session_id":id}), + Some(&id), + ) + .await; + assert!(result.success, "{result:?}"); + assert_eq!(result.result.unwrap().output["lifecycle"], "closed"); + for denied_auth in [ + Some(auth_context(Some("bob"), true)), + Some(open_auth_context()), + None, + ] { + let result = call( + &runtime, + denied_auth.as_ref(), + "read_session_summary", + json!({"session_id":id}), + None, + ) + .await; + assert!(!result.success, "foreign/anonymous history: {result:?}"); + } + let mut revoked_scope = auth.clone(); + revoked_scope.scopes.clear(); + assert!( + !call( + &runtime, + Some(&revoked_scope), + "close_session", + json!({"session_id":id}), + None + ) + .await + .success + ); + } +} + +#[tokio::test] +async fn retired_project_pending_inventory_does_not_prove_unregistration() { + let runtime = test_runtime(); + let auth = owner(true); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + runtime + .runner_registry + .register(RunnerRegisterRequest { + owner: Some("alice".into()), + ..crate::test_support::runner_registration( + CLIENT, + &format!("inst-{CLIENT}"), + RunnerCapabilities::default(), + ) + }) + .await + .unwrap(); + let view = runtime + .runner_registry + .get_runner_view(CLIENT) + .await + .unwrap(); + assert_ne!(view.project_inventory.unwrap().sync_state, "complete"); + for tool in ["read_session_summary", "close_session"] { + let result = call(&runtime, Some(&auth), tool, json!({"session_id":id}), None).await; + assert!( + !result.success, + "partial inventory is not removal evidence: {result:?}" + ); + } + register(&runtime, "alice", None).await; + assert!( + call( + &runtime, + Some(&auth), + "close_session", + json!({"session_id":id}), + None + ) + .await + .success + ); +} + +#[tokio::test] +async fn retired_project_reused_id_cannot_resume_old_session() { + let runtime = test_runtime(); + let auth = owner(false); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + register(&runtime, "alice", Some("/replacement-root")).await; + for tool in ["read_session_summary", "close_session"] { + let response = call(&runtime, Some(&auth), tool, json!({"session_id":id}), None).await; + assert!( + !response.success, + "reused root should reject history access" + ); + } + let result = call( + &runtime, + Some(&auth), + "update_session_context", + json!({ + "project":PROJECT, "session_id":id, "execution_context":{"default_cwd":"src"} + }), + None, + ) + .await; + assert!( + !result.success, + "same textual Project must not restore Session execution authority: {result:?}" + ); + assert_eq!( + result.result.unwrap().output["error_kind"], + "session_project_identity_changed" + ); + let result = call(&runtime, Some(&auth), "list_projects", json!({}), Some(&id)).await; + assert!( + !result.success, + "a replaced Project cannot be a live recorder: {result:?}" + ); +} + +#[tokio::test] +async fn retired_project_same_root_reregistration_cannot_resume_old_session() { + let runtime = test_runtime(); + let auth = owner(false); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + register(&runtime, "alice", Some("/original-root")).await; + + let result = call( + &runtime, + Some(&auth), + "update_session_context", + json!({ + "project": PROJECT, + "session_id": id, + "execution_context": {"default_cwd": "src"} + }), + None, + ) + .await; + assert!( + !result.success, + "re-registering the same root must not revive a retired Session: {result:?}" + ); + + // A stale Session must not enqueue a Runner command on the replacement, + // even when the Project selector and canonical root are byte-for-byte equal. + let forbidden = runtime + .dispatch_with_auth( + ToolCall::RunShell { + login: false, + project: PROJECT.to_string(), + command: "echo stale".to_string(), + session_id: Some(id), + timeout_secs: Some(10), + sync_wait_secs: None, + cwd: None, + purpose: None, + shell: None, + }, + Some(&auth), + ) + .await; + assert!( + !forbidden.success, + "stale Session must not execute: {forbidden:?}" + ); + assert_eq!( + forbidden.output["error_kind"], + "session_project_identity_changed" + ); + assert_ne!(forbidden.output["command_started"], true); + assert!( + probe_agent_request_for_instance(&runtime, CLIENT, &format!("inst-{CLIENT}")) + .await + .is_none() + ); +} + +#[tokio::test] +async fn project_inventory_reconnect_keeps_registration_identity() { + let runtime = test_runtime(); + let auth = owner(false); + let project = registered_project("repo", "/original-root"); + let original_incarnation = project.registration_incarnation.clone(); + assert!(original_incarnation.is_some()); + + register_agent_projects( + &runtime, + CLIENT, + Some("alice"), + RunnerCapabilities::default(), + vec![project.clone()], + ) + .await; + let id = start(&runtime, &auth).await; + + // Reconnect/re-publish the same persisted registration, not a fresh one. + register_agent_projects( + &runtime, + CLIENT, + Some("alice"), + RunnerCapabilities::default(), + vec![project], + ) + .await; + + let result = call( + &runtime, + Some(&auth), + "update_session_context", + json!({"project": PROJECT, "session_id": id, + "execution_context": {"default_cwd": "src"}}), + None, + ) + .await; + assert!( + result.success, + "a reconnect must not retire the same registration: {result:?}" + ); +} + +#[tokio::test] +async fn legacy_registration_without_incarnation_cannot_create_live_session() { + let runtime = test_runtime(); + let auth = owner(false); + let mut legacy = registered_project("repo", "/original-root"); + legacy.registration_incarnation = None; + register_agent_projects( + &runtime, + CLIENT, + Some("alice"), + RunnerCapabilities::default(), + vec![legacy], + ) + .await; + let result = call( + &runtime, + Some(&auth), + "start_session", + json!({"project": PROJECT}), + None, + ) + .await; + assert!( + !result.success, + "legacy path-only identity is not execution authority: {result:?}" + ); + assert_eq!( + result.result.unwrap().output["error_kind"], + "project_registration_identity_unavailable" + ); +} + +#[tokio::test] +async fn retired_project_close_commits_before_success_and_survives_reopen() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("webcodex.db"); + let legacy = directory.path().join("sessions.json"); + let runtime = test_runtime() + .with_session_database(&database, &legacy) + .unwrap(); + let auth = owner(false); + register(&runtime, "alice", Some("/original-root")).await; + let id = start(&runtime, &auth).await; + unregister(&runtime).await; + let result = call( + &runtime, + Some(&auth), + "close_session", + json!({"session_id":id}), + None, + ) + .await; + assert!(result.success, "{result:?}"); + // Reopen while the original writer is alive; dropping it would mask a missing flush. + let restored = test_runtime() + .with_session_database(&database, &legacy) + .unwrap(); + assert_eq!( + restored.sessions.session_project_identity(&id), + runtime.sessions.session_project_identity(&id), + "the durable Project root identity must survive a SQLite reopen" + ); + register(&restored, "alice", None).await; + assert_eq!( + restored.sessions.lifecycle_state(&id), + Some(sessions::SessionLifecycle::Closed) + ); + let result = call( + &restored, + Some(&auth), + "close_session", + json!({"session_id":id}), + Some(&id), + ) + .await; + assert!(result.success, "{result:?}"); + assert_eq!(result.result.unwrap().output["already_closed"], true); +} diff --git a/src/tool_runtime/tests/startup_brief.rs b/src/tool_runtime/tests/startup_brief.rs index c91cbdfca..3ff5955c6 100644 --- a/src/tool_runtime/tests/startup_brief.rs +++ b/src/tool_runtime/tests/startup_brief.rs @@ -920,10 +920,43 @@ async fn restart_restored_coding_task_session_reloads_rules_without_persisting_b assert!(!persisted.contains(marker)); assert!(!persisted.contains(source_marker)); assert!(!persisted.contains("Repository rules")); + let original_registration = runtime1 + .runner_registry + .get_runner_view("rules-restart") + .await + .unwrap() + .projects + .into_iter() + .find(|project| project.id == "demo") + .unwrap(); drop(runtime1); let runtime2 = ToolRuntime::new_for_tests().with_session_ledger(&ledger); - register_runner_project_at_path(&runtime2, "rules-restart", "demo", root.path()).await; + runtime2 + .runner_registry + .register(crate::test_support::runner_registration( + "rules-restart", + "inst", + RunnerCapabilities { + shell: true, + explicit_shell_selection: true, + bash_login_shell: true, + git: true, + file_read: true, + file_write: true, + internal_posix_script: true, + ..Default::default() + }, + )) + .await + .unwrap(); + crate::test_support::apply_project_inventory_snapshot( + &runtime2.runner_registry, + "rules-restart", + "inst", + vec![original_registration], + ) + .await; let restored = start( &runtime2, "rules-restart", diff --git a/src/tool_runtime/tests/support/runner.rs b/src/tool_runtime/tests/support/runner.rs index d51c33baf..5c5ad5014 100644 --- a/src/tool_runtime/tests/support/runner.rs +++ b/src/tool_runtime/tests/support/runner.rs @@ -845,6 +845,7 @@ pub(in crate::tool_runtime::tests) fn registered_project( disabled: false, revision: None, root_fingerprint: None, + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), lineage: None, git_branch: None, git_head: None, @@ -874,6 +875,7 @@ pub(in crate::tool_runtime::tests) fn named_registered_project( disabled: false, revision: None, root_fingerprint: None, + registration_incarnation: Some(uuid::Uuid::new_v4().to_string()), lineage: None, git_branch: None, git_head: None, diff --git a/src/tool_runtime/tests/work_on_project.rs b/src/tool_runtime/tests/work_on_project.rs index d40b5cf9a..6b5fc3879 100644 --- a/src/tool_runtime/tests/work_on_project.rs +++ b/src/tool_runtime/tests/work_on_project.rs @@ -494,6 +494,7 @@ async fn dispatch_with_managed_worktree_runner( "disabled": false, "revision": format!("sha256:{}", "b".repeat(64)), "root_fingerprint": managed_target_root_fingerprint(), + "registration_incarnation": "10000000-0000-4000-8000-000000000001", "lineage": { "kind": "managed_worktree_source", "source_project_id": "source", @@ -948,6 +949,7 @@ async fn dispatch_with_path_runner( "disabled": false, "revision": format!("sha256:{}", "a".repeat(64)), "root_fingerprint": format!("wc_projroot_{}", "b".repeat(64)), + "registration_incarnation": "10000000-0000-4000-8000-000000000002", "source": "path", "outcome": outcome, "registered": registered, diff --git a/src/tool_runtime/tests/work_on_project/bootstrap_and_path_source.rs b/src/tool_runtime/tests/work_on_project/bootstrap_and_path_source.rs index 171ea8752..43170f701 100644 --- a/src/tool_runtime/tests/work_on_project/bootstrap_and_path_source.rs +++ b/src/tool_runtime/tests/work_on_project/bootstrap_and_path_source.rs @@ -825,7 +825,14 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth let auth = auth_context(None, true); let owner_authority = crate::tool_runtime::workflow_session_authority_fingerprint(Some(&auth)) .expect("test auth has stable Workflow Session authority"); + let registered_id = project.clone(); + let registered_identity = crate::test_support::registered_session_project_identity(&runtime, &project, Some(&auth)).await; let start_owned_session = |project: Option, title: Option| { + let project_identity = if project.as_deref() == Some(registered_id.as_str()) { + registered_identity.clone() + } else { + None + }; runtime .sessions .start_session_with_options( @@ -835,7 +842,8 @@ async fn workflow_resume_context_is_window_principal_scoped_bounded_and_non_auth SessionMode::Normal, SessionGuards::default(), ) - .with_owner_authority_fingerprint(Some(owner_authority.clone())), + .with_owner_authority_fingerprint(Some(owner_authority.clone())) + .with_project_identity(project_identity), ) .unwrap() }; diff --git a/src/tool_runtime/tests/work_result/frozen_changes.rs b/src/tool_runtime/tests/work_result/frozen_changes.rs index 5c1b3f3fb..7824dba76 100644 --- a/src/tool_runtime/tests/work_result/frozen_changes.rs +++ b/src/tool_runtime/tests/work_result/frozen_changes.rs @@ -22,18 +22,22 @@ fn git(root: &Path, args: &[&str]) -> String { String::from_utf8(output.stdout).unwrap().trim().to_string() } -fn start_changes_session( +async fn start_changes_session( runtime: &ToolRuntime, auth: &crate::auth::AuthContext, project: &str, baseline: String, ) -> crate::tool_runtime::SessionSummary { + let project_identity = + crate::test_support::registered_session_project_identity(runtime, project, Some(auth)) + .await; let authority_fingerprint = crate::tool_runtime::workflow_session_authority_fingerprint(Some(auth)).unwrap(); runtime .sessions .ensure_coding_session_with_git_baseline( CodingSessionRequest { + project_identity, project: project.to_string(), authority_fingerprint, resume_session_id: None, @@ -520,7 +524,7 @@ async fn work_result_file_pages_share_frozen_source_and_never_require_a_session( assert!(!denied.success, "{path}"); } let baseline = git(tmp.path(), &["rev-parse", "HEAD^{tree}"]); - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "file_00.txt"); let sealed = seal_successful_closeout(&runtime, client, &project, &session.session_id, &auth) .await @@ -608,7 +612,7 @@ async fn final_changes_uses_startup_tree_whole_final_workspace_and_frozen_lazy_d let project = register_runner_project_at_path_with_auth(&runtime, client_id, "demo", tmp.path(), &auth) .await; - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "rename_me.rs"); fs::rename( @@ -806,7 +810,7 @@ async fn final_changes_uses_startup_tree_whole_final_workspace_and_frozen_lazy_d .unwrap() .git_baseline_tree .expect("other session baseline"); - let other_session = start_changes_session(&runtime, &auth, &project, other_baseline); + let other_session = start_changes_session(&runtime, &auth, &project, other_baseline).await; let wrong_session = runtime .changes_file_diff( project.clone(), @@ -1004,7 +1008,7 @@ async fn final_changes_neutralizes_repository_configured_clean_and_process_filte let project = register_runner_project_at_path_with_auth(&runtime, client_id, "demo", tmp.path(), &auth) .await; - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "clean.dat"); let summary = runtime.sessions.summary(&session.session_id, None).unwrap(); @@ -1047,7 +1051,7 @@ async fn committed_final_tree_is_presentable_even_when_worktree_is_clean() { let project = register_runner_project_at_path_with_auth(&runtime, client_id, "demo", tmp.path(), &auth) .await; - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "README.md"); fs::write(tmp.path().join("README.md"), "committed task state\n").unwrap(); git(tmp.path(), &["add", "README.md"]); @@ -1085,7 +1089,7 @@ async fn final_changes_seal_survives_model_summary_tail_truncation() { let project = register_runner_project_at_path_with_auth(&runtime, client_id, "demo", tmp.path(), &auth) .await; - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "README.md"); fs::write(tmp.path().join("README.md"), "long task final state\n").unwrap(); @@ -1138,7 +1142,7 @@ async fn shell_only_is_ineligible_and_reverted_first_class_edit_has_no_presentat register_runner_project_at_path_with_auth(&runtime, client_id, "demo", tmp.path(), &auth) .await; - let shell_only = start_changes_session(&runtime, &auth, &project, baseline.clone()); + let shell_only = start_changes_session(&runtime, &auth, &project, baseline.clone()).await; fs::write(tmp.path().join("shell-only.rs"), "shell write\n").unwrap(); let summary = runtime .sessions @@ -1159,7 +1163,7 @@ async fn shell_only_is_ineligible_and_reverted_first_class_edit_has_no_presentat assert!(work.output["work_result"].get("final_changes").is_none()); fs::remove_file(tmp.path().join("shell-only.rs")).unwrap(); - let reverted = start_changes_session(&runtime, &auth, &project, baseline); + let reverted = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &reverted.session_id, &project, "README.md"); fs::write(tmp.path().join("README.md"), "temporary\n").unwrap(); git(tmp.path(), &["restore", "--source=HEAD", "--", "README.md"]); @@ -1381,7 +1385,7 @@ async fn work_result_content_pages_preserve_utf8_snapshot_scope_and_preview_boun // Explicit final content reads use the sealed final tree and original Session; // a later edit cannot retarget them into either the workspace or another scope. let baseline = git(tmp.path(), &["rev-parse", "HEAD^{tree}"]); - let session = start_changes_session(&runtime, &auth, &project, baseline); + let session = start_changes_session(&runtime, &auth, &project, baseline).await; record_first_class_edit(&runtime, &session.session_id, &project, "unicode.md"); let sealed = seal_successful_closeout(&runtime, client, &project, &session.session_id, &auth) .await diff --git a/src/tool_runtime/tests/workspace_reads.rs b/src/tool_runtime/tests/workspace_reads.rs index e17809210..52457f4ee 100644 --- a/src/tool_runtime/tests/workspace_reads.rs +++ b/src/tool_runtime/tests/workspace_reads.rs @@ -12,6 +12,7 @@ fn resolved() -> ResolvedProject { allow_patch: true, }, root_fingerprint: Some("root-incarnation".into()), + registration_incarnation: None, knowledge_association: None, } }