From 85f70d4bc1916b2c35a02a3f5856796910be9464 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 26 Oct 2026 06:20:22 +0900 Subject: [PATCH] test: adapt fixed Worker restore fixtures to integration lane --- Cargo.lock | 6 + crates/worker-runtime/Cargo.toml | 2 + crates/worker-runtime/README.md | 31 + crates/worker-runtime/src/fs_store.rs | 4 + crates/worker-runtime/src/lib.rs | 2 + crates/worker-runtime/src/test_support.rs | 859 +++ .../worker-runtime/src/test_support_tests.rs | 885 +++ crates/worker-runtime/src/worker_backend.rs | 9 + crates/workspace-server/Cargo.toml | 4 + crates/workspace-server/src/lib.rs | 2 + crates/workspace-server/src/store.rs | 42 +- crates/workspace-server/src/test_support.rs | 688 ++ tests/e2e/Cargo.toml | 12 + tests/e2e/README.md | 38 + .../fixtures/worker-codex-v1/expected.json | 52 + .../fixtures/worker-codex-v1/manifest.json | 34 + .../worker-codex-v1/worker-template.json | 5605 +++++++++++++++++ .../worker-codex-v1/workspace-config.dcdl | 22 + tests/e2e/src/fixture_source.rs | 336 + tests/e2e/src/lib.rs | 40 +- tests/e2e/src/worker_environment.rs | 1178 +++- tests/e2e/src/worker_session.rs | 284 + tests/e2e/tests/panel.rs | 10 +- tests/e2e/tests/worker_restore.rs | 274 + tests/e2e/tests/worker_restore/fixture.rs | 257 + tests/e2e/tests/worker_restore/seed.rs | 482 ++ tools/state-contract-name-exceptions.json | 33 + 27 files changed, 11036 insertions(+), 155 deletions(-) create mode 100644 crates/worker-runtime/src/test_support.rs create mode 100644 crates/worker-runtime/src/test_support_tests.rs create mode 100644 crates/workspace-server/src/test_support.rs create mode 100644 tests/e2e/fixtures/worker-codex-v1/expected.json create mode 100644 tests/e2e/fixtures/worker-codex-v1/manifest.json create mode 100644 tests/e2e/fixtures/worker-codex-v1/worker-template.json create mode 100644 tests/e2e/fixtures/worker-codex-v1/workspace-config.dcdl create mode 100644 tests/e2e/src/fixture_source.rs create mode 100644 tests/e2e/src/worker_session.rs create mode 100644 tests/e2e/tests/worker_restore.rs create mode 100644 tests/e2e/tests/worker_restore/fixture.rs create mode 100644 tests/e2e/tests/worker_restore/seed.rs diff --git a/Cargo.lock b/Cargo.lock index f2901cb1f..c88fc1d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6707,15 +6707,21 @@ name = "yoi-e2e" version = "0.0.0" dependencies = [ "chrono", + "config-source", "libc", + "manifest", + "protocol", "reqwest", "serde", "serde_json", "server-api", + "session-store", "sha2 0.11.0", "tempfile", + "tungstenite 0.29.0", "webauthn-authenticator-rs", "webauthn-rs-proto", + "worker-runtime", "yoi-workspace-server", ] diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 0eba6a589..32f6ea537 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -19,6 +19,8 @@ required-features = ["ws-server", "fs-store"] [features] default = ["ws-server", "fs-store"] fs-store = ["dep:fs4"] +# Offline fixture tooling only; never enabled by the product defaults. +test-support = ["fs-store", "ws-server"] http-server = ["dep:axum", "dep:tower", "dep:reqwest"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] diff --git a/crates/worker-runtime/README.md b/crates/worker-runtime/README.md index 4984c2e18..e4f041745 100644 --- a/crates/worker-runtime/README.md +++ b/crates/worker-runtime/README.md @@ -8,3 +8,34 @@ Manages Worker creation, stopping, and Restore, execution connections within the - [`execution`] — execution backends and operation-specific results. - [`working_directory`] — repository materialization, session resources, and safe deletion. - [`workspace_issuer`] — Workspace signature verification and replay prevention. + +## Offline stopped-Worker fixtures + +The nondefault `test-support` feature exposes `test_support::StoppedWorkerTemplate`. +It is intentionally limited to current-schema, stopped, attachment-free artificial +conversations with one Segment, text history, and no pending execution/activation. +It does not add a product endpoint or change create/restore authorization. + +1. With the source Runtime offline, call `StoppedWorkerTemplate::extract(worker_dir)`. + The source must have its existing Runtime owner lock and identity. Extraction is + read-only; the returned serde template excludes source auth, saved Manifest, + request/config references, run directories, and Runtime operation history. +2. Initialize **new** Runtime identity and Workspace trust in a fresh destination + using normal setup. Preserve the template's Runtime and Workspace IDs. +3. Call `template.install(&options, request, &resolved_manifest)` before starting + the Runtime. Supply a fresh typed `CreateWorkerRequest` (including config/profile + references and trusted memory settings) and `manifest::WorkerManifest` resolved + for that environment. Its Worker name must equal `template.worker_name()`. +4. Start normally and use the ordinary explicit restore operation. Installation + itself creates no execution and leaves the Worker stopped with a fresh guard. + +The template preserves the original Session manifest/JSONL bytes, including stable +entry IDs, timestamps, and settled submission receipts. It rejects resumable queues +and Feature invocation history. A saved system prompt is required for restore; +checkpoint receipts must match the Worker's typed receipt schema. Preserved records +must use canonical writer field shapes: unknown/duplicate fields (including nested +fields), discarded optional fields, and opaque Feature history payloads are rejected, +not stripped or rewritten. Portability/secret-marker checks do not replace +review of the supplied artificial conversation or its independently verified hashes. +The installer refuses any destination content other than initialized `auth.toml`; +interrupted publication is fail-closed, not an overwrite/recovery facility. diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 21ecfeca8..dcfb1b42c 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -39,6 +39,10 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +#[cfg(feature = "test-support")] +#[path = "test_support.rs"] +pub mod test_support; + const SCHEMA_VERSION: u32 = 12; // Frozen source schemas: do not move their meaning when adding a migration. const SOURCE_SCHEMA_VERSION: u32 = 11; diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index f4a5b02ff..bb72c71e3 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -35,6 +35,8 @@ pub mod workspace_issuer; #[cfg(feature = "http-server")] pub mod workspace_request; +#[cfg(feature = "test-support")] +pub use fs_store::test_support; #[cfg(feature = "fs-store")] pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use management::RuntimeOptions; diff --git a/crates/worker-runtime/src/test_support.rs b/crates/worker-runtime/src/test_support.rs new file mode 100644 index 000000000..fb0f32693 --- /dev/null +++ b/crates/worker-runtime/src/test_support.rs @@ -0,0 +1,859 @@ +//! Offline support for the approved, attachment-free stopped Worker fixture. +//! +//! Enabled only by `test-support`. This is not an import API: extraction accepts a +//! current-schema, settled, single-Segment aggregate; installation accepts only a +//! fresh Runtime directory containing its newly initialized `auth.toml`. No +//! Runtime or execution backend is constructed. Original Session bytes are never +//! rewritten. The caller must separately review/attest the artificial conversation; +//! structural checks and secret-marker checks cannot prove arbitrary prose safe. + +use super::*; +use crate::catalog::ProfileSourceArchiveSource; +use crate::workspace_issuer::{ + WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, validate_workspace_issuer_trust_records, +}; +use session_store::{ + SegmentId, SessionId, WorkerActiveSegmentRef, WorkerMetadata, WorkerSessionStore, +}; + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; + +const TEMPLATE_VERSION: u32 = 1; +const MAX_SESSION_BYTES: usize = 8 * 1024 * 1024; + +/// Portable fixture, intentionally without Debug (Session material is private). +/// Fields are private and every deserialized template is validated on install. +/// Serialize this value to retain the fixture; do not serialize its source aggregate. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StoppedWorkerTemplate { + template_version: u32, + runtime_schema_version: u32, + runtime_id: String, + workspace_id: String, + worker_id: WorkerId, + created_at_ms: Option, + session_id: SessionId, + segment_id: SegmentId, + session_manifest: Vec, + segment_jsonl: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionManifest { + schema_version: u32, + session_id: SessionId, +} + +// Read-only projection: never retain source key material in a template. +#[derive(Deserialize)] +struct AuthIdentity { + identity_id: String, +} + +#[derive(Deserialize)] +struct SourceAuth { + identity: AuthIdentity, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InitializedAuth { + identity: crate::auth::RuntimeIdentityMaterial, + workspace_issuers: Vec, + #[serde(default)] + archived_legacy_issuers: Vec, +} + +#[derive(Deserialize)] +struct GuardState { + active_request_id: Option, + owners: BTreeMap, +} + +#[derive(Deserialize)] +struct GuardOwner { + receipt: Option, +} + +fn reject(message: &'static str) -> RuntimeError { + RuntimeError::InvalidRequest(format!("stopped Worker fixture: {message}")) +} + +fn io(result: std::io::Result) -> Result { + result.map_err(|_| reject("filesystem operation failed")) +} + +fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|_| reject("invalid typed document")) +} + +fn no_symlink_components(path: &Path) -> Result<(), RuntimeError> { + let mut current = PathBuf::new(); + for part in path.components() { + if matches!(part, std::path::Component::ParentDir) { + return Err(reject("parent traversal is unsupported")); + } + current.push(part); + let metadata = io(fs::symlink_metadata(¤t))?; + if metadata.file_type().is_symlink() { + return Err(reject("symlinks are unsupported")); + } + } + Ok(()) +} + +fn check_tree(path: &Path, budget: &mut usize) -> Result<(), RuntimeError> { + if *budget == 0 { + return Err(reject("file count exceeds fixture limit")); + } + *budget -= 1; + let metadata = io(fs::symlink_metadata(path))?; + if metadata.is_dir() { + for entry in io(fs::read_dir(path))? { + check_tree(&io(entry)?.path(), budget)?; + } + } else if !metadata.is_file() { + return Err(reject("symlinks and special files are unsupported")); + } + Ok(()) +} + +fn regular_file(path: &Path) -> Result<(), RuntimeError> { + no_symlink_components(path)?; + if !io(fs::symlink_metadata(path))?.is_file() { + return Err(reject("expected a regular file")); + } + Ok(()) +} + +fn bytes(path: &Path) -> Result, RuntimeError> { + regular_file(path)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOATIME | libc::O_NOFOLLOW); + } + let file = io(options.open(path))?; + let mut result = Vec::new(); + io(file + .take(MAX_SESSION_BYTES as u64 + 1) + .read_to_end(&mut result))?; + if result.len() > MAX_SESSION_BYTES { + return Err(reject("file exceeds fixture limit")); + } + Ok(result) +} + +fn text(path: &Path) -> Result { + String::from_utf8(bytes(path)?).map_err(|_| reject("invalid UTF-8 document")) +} + +fn portable_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 256 + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_".contains(&c)) +} + +impl StoppedWorkerTemplate { + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + pub fn workspace_id(&self) -> &str { + &self.workspace_id + } + pub fn worker_id(&self) -> WorkerId { + self.worker_id + } + pub fn worker_name(&self) -> String { + runtime_worker_name(self.worker_id) + } + pub fn session_id(&self) -> SessionId { + self.session_id + } + pub fn segment_id(&self) -> SegmentId { + self.segment_id + } + + /// Extract without migration, rewriting metadata, or copying credentials, + /// resolved manifests, execution-operation receipts, or run directories. + /// The source Runtime must be offline; its existing owner lock is held for + /// the entire read. The path must be `/workers/`. + pub fn extract(worker_dir: &Path) -> Result { + no_symlink_components(worker_dir)?; + let workers = worker_dir + .parent() + .ok_or_else(|| reject("invalid aggregate path"))?; + if workers.file_name().and_then(|s| s.to_str()) != Some(WORKERS_DIR) { + return Err(reject("invalid aggregate path")); + } + let root = workers + .parent() + .ok_or_else(|| reject("invalid Runtime path"))?; + // Require the existing lock, rather than creating a file in the source. + let lock_path = root.with_file_name(format!( + ".{}.runtime-owner.lock", + root.file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| reject("invalid Runtime path"))? + )); + regular_file(&lock_path)?; + let lock = io(OpenOptions::new().read(true).open(lock_path))?; + if !io(FileExt::try_lock_shared(&lock))? { + return Err(reject("source Runtime is not offline")); + } + let _lock = RuntimeStoreOwnerLock { file: lock }; + check_tree(worker_dir, &mut 256)?; + let snapshot: RuntimeSnapshot = decode(&bytes(&root.join(RUNTIME_FILE))?)?; + snapshot + .validate(&root.join(RUNTIME_FILE)) + .map_err(|_| reject("unsupported Runtime schema"))?; + if !snapshot.subject_create_recovery_fences.is_empty() + || !snapshot.subject_releases.is_empty() + { + return Err(reject("source has lifecycle operations")); + } + let record: WorkerAggregateRecord = decode(&bytes(&worker_dir.join(WORKER_FILE))?)?; + let record = record + .validate(&worker_dir.join(WORKER_FILE)) + .map_err(|_| reject("invalid Worker schema or identity"))?; + let guard: GuardState = decode( + &serde_json::to_vec(&record.restore_guard) + .map_err(|_| reject("invalid restore guard"))?, + )?; + if record.status != WorkerStatus::Stopped + || guard.active_request_id.is_some() + || guard.owners.values().any(|owner| owner.receipt.is_none()) + { + return Err(reject("Worker is not settled and stopped")); + } + let PersistedWorkerExecutionState::Available(execution) = &record.execution_state else { + return Err(reject("pending or unavailable execution metadata")); + }; + // A stopped aggregate may retain the empty historical binding marker. + // It is deliberately not copied to the installed Worker. + if execution.restore_intent != WorkerRestoreIntent::Explicit { + return Err(reject("Worker requires explicit restore")); + } + if !record.workdir_attachments.is_empty() || !record.logical_workdir_attachments.is_empty() + { + return Err(reject("attachments are unsupported")); + } + check_request(&execution.request, false)?; + let workspace_id = record + .workspace_id + .ok_or_else(|| reject("missing Workspace identity"))?; + let metadata: WorkerMetadata = decode(&bytes(&worker_dir.join(WORKER_METADATA_FILE))?)?; + crate::worker_backend::validate_fixture_restore_metadata(&metadata, &execution.request) + .map_err(|_| reject("invalid source restore Manifest or settings"))?; + if metadata.worker_name != runtime_worker_name(record.worker_id) + || metadata.workspace_id.as_deref() != Some(&workspace_id) + || !metadata.spawned_children.is_empty() + || !metadata.reclaimed_children.is_empty() + || !metadata.peers.is_empty() + || metadata.subjektiv_session_attribution.is_some() + { + return Err(reject("unsupported or inconsistent Worker metadata")); + } + let active = metadata + .active + .ok_or_else(|| reject("missing active Session"))?; + let segment_id = active + .segment_id + .ok_or_else(|| reject("unmaterialized Session"))?; + let source_auth: SourceAuth = toml::from_str(&text(&root.join("auth.toml"))?) + .map_err(|_| reject("missing Runtime identity"))?; + let session = worker_dir.join("session"); + let fixture = Self { + template_version: TEMPLATE_VERSION, + runtime_schema_version: SCHEMA_VERSION, + runtime_id: source_auth.identity.identity_id, + workspace_id, + worker_id: record.worker_id, + created_at_ms: record.created_at_ms, + session_id: active.session_id, + segment_id, + session_manifest: bytes(&session.join("session.json"))?, + segment_jsonl: bytes(&session.join("segments").join(format!("{segment_id}.jsonl")))?, + }; + fixture.validate_shape()?; + fixture.validate_session(&session)?; + Ok(fixture) + } + + /// Install after normal identity/trust initialization, before Runtime start. + /// `request` and `manifest` supply NEW backend/config/profile references and + /// freshly resolved policy, not a launch. The saved source Manifest is never + /// reused. `manifest.worker.name` must equal `worker_name()`. + /// Only `auth.toml` may already exist. The owner lock fences live Runtimes; + /// staged files are synced and published without overwriting any Worker. + /// An interrupted publication fails closed as an incomplete Runtime layout. + pub fn install( + &self, + options: &FsRuntimeStoreOptions, + request: CreateWorkerRequest, + manifest: &manifest::WorkerManifest, + ) -> Result<(), RuntimeError> { + self.validate_shape()?; + check_request(&request, true)?; + if manifest.worker.name != self.worker_name() { + return Err(reject("resolved Manifest Worker name mismatch")); + } + let mut metadata = WorkerMetadata::new( + self.worker_name(), + Some(WorkerActiveSegmentRef { + session_id: self.session_id, + segment_id: Some(self.segment_id), + }), + ) + .with_workspace_id(&self.workspace_id); + metadata.resolved_manifest_snapshot = Some( + manifest::write_persisted_worker_manifest_snapshot(manifest) + .map_err(|_| reject("invalid resolved Manifest"))?, + ); + crate::worker_backend::validate_fixture_restore_metadata(&metadata, &request) + .map_err(|_| reject("resolved Manifest does not match fresh request settings"))?; + if options.runtime_id != self.runtime_id + || request.worker_id != self.worker_id + || request + .workspace_api + .as_ref() + .map(|api| api.workspace_id.as_str()) + != Some(&self.workspace_id) + { + return Err(reject("installation identity mismatch")); + } + no_symlink_components(&options.root)?; + check_tree(&options.root, &mut 256)?; + // Validate the lock location before the ordinary owner-lock opener. + let lock_path = options.root.with_file_name(format!( + ".{}.runtime-owner.lock", + options + .root + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| reject("invalid Runtime path"))? + )); + if fs::symlink_metadata(&lock_path).is_ok() { + regular_file(&lock_path)?; + } + let lock = acquire_runtime_store_owner_lock(&options.root)?; + validate_fresh_runtime_store_layout(&options.root) + .map_err(|_| reject("destination is not fresh; refusing overwrite"))?; + let auth: InitializedAuth = toml::from_str(&text(&options.root.join("auth.toml"))?) + .map_err(|_| reject("initialize Runtime identity and trust before install"))?; + if auth.identity.identity_id != self.runtime_id + || !auth.archived_legacy_issuers.is_empty() + || auth.identity.signing_key().is_err() + { + return Err(reject("invalid destination Runtime identity")); + } + validate_workspace_issuer_trust_records(&auth.workspace_issuers) + .map_err(|_| reject("invalid destination Workspace trust"))?; + let api = request + .workspace_api + .as_ref() + .expect("validated Workspace binding"); + if !auth.workspace_issuers.iter().any(|trust| { + trust.workspace_id == self.workspace_id + && trust.backend_url == api.base_url + && trust.state == WorkspaceIssuerTrustState::Active + }) { + return Err(reject("request does not match initialized Workspace trust")); + } + let stage = options + .root + .join(format!(".fixture-{}", uuid::Uuid::now_v7())); + io(fs::create_dir(&stage))?; + let store = FsRuntimeStore { + root: stage.clone(), + _owner_lock: Some(lock), + worker_write_fault: Arc::new(AtomicU8::new(AtomicWriteFault::None as u8)), + }; + let result = (|| { + let worker = PersistedWorkerRecord { + restore_guard: crate::runtime::RestoreGuard::default(), + worker_ref: WorkerRef::new(self.worker_id), + worker_id: self.worker_id, + profile: request.profile.clone(), + display_name: request.display_name.clone(), + profile_source: request.profile_source.reference(), + config_bundle: request.config_bundle.clone(), + created_at_ms: self.created_at_ms, + status: WorkerStatus::Stopped, + last_finished_submission_request_id: None, + workspace_id: Some(self.workspace_id.clone()), + workdir_attachments: Vec::new(), + logical_workdir_attachments: Vec::new(), + execution_state: PersistedWorkerExecutionState::Available( + PersistedWorkerExecution { + request, + binding: None, + restore_intent: WorkerRestoreIntent::Explicit, + }, + ), + }; + WorkerAggregateRecord::from_persisted(&worker) + .validate(&store.worker_dir(&self.worker_id).join(WORKER_FILE)) + .map_err(|_| reject("invalid installation request"))?; + store.write_worker_record(&worker)?; + let worker_dir = store.worker_dir(&self.worker_id); + atomic_write_json( + &worker_dir.join(WORKER_METADATA_FILE), + &metadata, + "write fixture metadata", + )?; + let session = worker_dir.join("session"); + io(fs::create_dir_all(session.join("segments")))?; + write_new(&session.join("session.json"), &self.session_manifest)?; + write_new( + &session + .join("segments") + .join(format!("{}.jsonl", self.segment_id)), + &self.segment_jsonl, + )?; + self.validate_session(&session)?; + store.write_runtime_snapshot(&PersistedRuntimeState { + subject_create_recovery_fences: BTreeMap::new(), + subject_releases: Vec::new(), + // Match normal store initialization: this is catalog availability, + // not a process or Worker execution start. + display_name: options.display_name.clone(), + status: RuntimeStatus::Running, + next_diagnostic_id: 1, + workers: BTreeMap::new(), + workspace_owners: BTreeMap::new(), + diagnostics: Vec::new(), + })?; + let loaded = store.load_runtime_state()?; + if loaded.workers.len() != 1 || !loaded.diagnostics.is_empty() { + return Err(reject("staged aggregate failed ordinary store roundtrip")); + } + sync_runtime_tree(&stage)?; + // No destination existed when checked under the owner lock. Hard-link + // the snapshot last with create-new semantics; never replace a file. + // Reserve the destination catalog with create-new semantics. Even + // an empty preexisting workers directory must never be replaced. + let destination_workers = options.root.join(WORKERS_DIR); + io(fs::create_dir(&destination_workers))?; + io(fs::rename( + store.worker_dir(&self.worker_id), + destination_workers.join(self.worker_id.to_string()), + ))?; + sync_directory(&destination_workers, "publish stopped Worker aggregate")?; + io(fs::hard_link( + stage.join(RUNTIME_FILE), + options.root.join(RUNTIME_FILE), + ))?; + sync_directory(&options.root, "publish stopped fixture")?; + Ok(()) + })(); + // Only remove our private staging directory, never existing destination data. + let cleanup = fs::remove_dir_all(&stage); + result?; + io(cleanup)?; + sync_directory(&options.root, "finish stopped fixture") + } + + fn validate_shape(&self) -> Result<(), RuntimeError> { + if self.template_version != TEMPLATE_VERSION + || self.runtime_schema_version != SCHEMA_VERSION + { + return Err(reject("unsupported template schema")); + } + if !portable_id(&self.runtime_id) + || !portable_id(&self.workspace_id) + || self.session_id.get_version_num() != 7 + || self.segment_id.get_version_num() != 7 + || self.session_manifest.len() > MAX_SESSION_BYTES + || self.segment_jsonl.len() > MAX_SESSION_BYTES + { + return Err(reject("invalid portable identity or size")); + } + let manifest: SessionManifest = decode(&self.session_manifest)?; + if manifest.session_id != self.session_id + || manifest.schema_version == 0 + || !self.segment_jsonl.ends_with(b"\n") + { + return Err(reject("Session identity or committed boundary mismatch")); + } + // Read-only JSON traversal, never mutation/reconstruction of Session bytes. + // Reject known secret/host markers even in otherwise valid typed records. + for line in self + .segment_jsonl + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + { + let _: PortableJson = decode(line)?; + validate_preserved_record(line)?; + } + Ok(()) + } + + fn validate_history_entry( + &self, + entry: &session_store::LoggedHistoryEntry, + ids: &mut std::collections::BTreeSet, + ) -> Result<(), RuntimeError> { + if !portable_id(&entry.metadata.entry_id.0) + || !ids.insert(entry.metadata.entry_id.0.clone()) + { + return Err(reject("duplicate or invalid entry identity")); + } + if entry + .metadata + .input_metadata + .as_ref() + .is_some_and(|metadata| { + metadata.feature_payload().is_some() || metadata.public_item().is_some() + }) + { + // These are opaque owner-specific payloads, not part of the narrow + // artificial conversation contract. Never preserve unchecked JSON. + return Err(reject("Feature history payloads are unsupported")); + } + if !matches!( + entry.item, + session_store::LoggedItem::Message { .. } + | session_store::LoggedItem::AssistantResponseBoundary { .. } + ) { + return Err(reject("only text conversation records are supported")); + } + if let session_store::LoggedSessionHistoryOrigin::ModelOutput { worker } = + &entry.metadata.origin + { + // Session producer IDs identify the original execution, not the + // Workspace catalog Worker. Preserve them; do not rebind/rewrite them. + if !portable_id(&worker.worker_id) + || worker + .workspace_id + .as_ref() + .is_some_and(|id| id != &self.workspace_id) + || worker + .runtime_id + .as_ref() + .is_some_and(|id| id != &self.runtime_id) + { + return Err(reject("Session origin identity mismatch")); + } + } + Ok(()) + } + + fn validate_session(&self, root: &Path) -> Result<(), RuntimeError> { + for entry in io(fs::read_dir(root))? { + let entry = io(entry)?; + match entry.file_name().to_str() { + Some("session.json" | "segments") => {} + Some("artifacts") => check_empty_artifacts(&entry.path())?, + _ => return Err(reject("unsupported Session files")), + } + } + let entries = io(fs::read_dir(root.join("segments")))?.collect::, _>>(); + let entries = io(entries)?; + if entries.len() != 1 + || entries[0].file_name() != format!("{}.jsonl", self.segment_id).as_str() + { + return Err(reject("only the fixed single-Segment fixture is supported")); + } + let store = WorkerSessionStore::open_read_only(root) + .map_err(|_| reject("unsupported Session schema"))?; + let logs = store + .read_all_read_only_bounded(self.session_id, self.segment_id, MAX_SESSION_BYTES as u64) + .map_err(|_| reject("invalid canonical Session records"))? + .0; + if !matches!(logs.first(), Some(session_store::LogEntry::AnnotatedSegmentStart { + session_id, system_prompt: Some(_), forked_from: None, compacted_from: None, .. + }) if *session_id == self.session_id) + { + return Err(reject( + "missing system prompt, invalid Segment seed, or unsupported lineage", + )); + } + let mut ids = std::collections::BTreeSet::new(); + for (index, log) in logs.iter().enumerate() { + use session_store::LogEntry; + match log { + LogEntry::AnnotatedSegmentStart { history, .. } + if index == 0 && history.is_empty() => {} + LogEntry::AnnotatedUserInput { + history, + extensions, + .. + } => { + for extension in extensions { + validate_settled_activation(extension)?; + } + for entry in history { + self.validate_history_entry(entry, &mut ids)?; + } + } + LogEntry::AnnotatedAssistantItem { entry, .. } => { + self.validate_history_entry(entry, &mut ids)? + } + LogEntry::RunCompleted { + entry_id: Some(id), .. + } => { + if !portable_id(&id.0) || !ids.insert(id.0.clone()) { + return Err(reject("duplicate or invalid entry identity")); + } + } + LogEntry::Invoke { .. } | LogEntry::LlmUsage { .. } | LogEntry::TurnEnd { .. } => {} + _ => { + return Err(reject( + "unsupported conversation record or pending operation", + )); + } + } + } + let state = session_store::collect_state(&logs); + if state.last_run_interrupted || state.active_run_turn_count.is_some() { + return Err(reject("Session has pending execution")); + } + for segments in &state.user_segments { + if segments + .iter() + .any(|segment| !matches!(segment, protocol::Segment::Text { .. })) + { + return Err(reject("Session attachments are unsupported")); + } + } + Ok(()) + } +} + +// Compare *fields*, not re-encoded bytes or scalar values (e.g. f32 JSON +// normalization). Serde's permissive/untagged/flattened compatibility readers +// can drop unknown data without surfacing it to an ignored-field callback. A +// retained field must survive the owner's typed projection at every depth. +// This intentionally accepts only canonical writer shapes: even an explicit +// empty optional field omitted by that writer is rejected, not repaired. +// The projection is validation-only; installation always writes original bytes. +fn validate_preserved_record(bytes: &[u8]) -> Result<(), RuntimeError> { + let original: serde_json::Value = decode(bytes)?; + let record: session_store::LogEntry = decode(bytes)?; + let projection = + serde_json::to_value(&record).map_err(|_| reject("invalid typed Session record"))?; + require_preserved_fields(&original, &projection) +} + +fn require_preserved_fields( + original: &serde_json::Value, + projection: &serde_json::Value, +) -> Result<(), RuntimeError> { + use serde_json::Value; + match (original, projection) { + (Value::Object(original), Value::Object(projection)) => { + for (key, value) in original { + let projected = projection + .get(key) + .ok_or_else(|| reject("unknown or noncanonical Session field"))?; + require_preserved_fields(value, projected)?; + } + } + (Value::Array(original), Value::Array(projection)) + if original.len() == projection.len() => + { + for (value, projected) in original.iter().zip(projection) { + require_preserved_fields(value, projected)?; + } + } + (Value::Null, Value::Null) + | (Value::Bool(_), Value::Bool(_)) + | (Value::Number(_), Value::Number(_)) + | (Value::String(_), Value::String(_)) => {} + _ => return Err(reject("noncanonical Session field shape")), + } + Ok(()) +} + +// Exact receipt wire fields owned by worker::PendingActivationState. Keep these +// fixture-only mirrors strict; do not change the production compatibility reader. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubmissionReceipt { + submission_request_id: String, + source_namespace: String, + submission_id: String, + payload_digest: String, + disposition: protocol::SubmissionDisposition, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NotificationReceipt { + notification_request_id: String, + source_namespace: String, + payload_digest: String, +} + +// The approved conversation has committed submission checkpoints in its original +// Session records. Keep their receipt bytes, but reject any resumable queue or +// Feature invocation history. This projection confers no execution authority. +fn validate_settled_activation( + extension: &session_store::SessionExtension, +) -> Result<(), RuntimeError> { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Checkpoint { + #[serde(rename = "next_activation_sequence")] + _next_activation_sequence: u64, + activating: Option, + activating_notification: Vec, + pending: Vec, + pending_notifications: Vec, + #[serde(rename = "receipts")] + _receipts: Vec, + #[serde(rename = "notification_receipts")] + _notification_receipts: Vec, + invocation_receipts: Vec, + invocation_recovery_blocked: bool, + } + if extension.domain != "worker.pending_activations.v1" { + return Err(reject("unsupported Session extension")); + } + let checkpoint: Checkpoint = serde_json::from_value(extension.payload.clone()) + .map_err(|_| reject("invalid Session activation checkpoint"))?; + if checkpoint.activating.is_some() + || !checkpoint.activating_notification.is_empty() + || !checkpoint.pending.is_empty() + || !checkpoint.pending_notifications.is_empty() + || !checkpoint.invocation_receipts.is_empty() + || checkpoint.invocation_recovery_blocked + { + return Err(reject( + "Session has pending activation or invocation history", + )); + } + Ok(()) +} + +fn check_request(request: &CreateWorkerRequest, install: bool) -> Result<(), RuntimeError> { + if !request.workdir_attachment_requests.is_empty() + || !request.workdir_attachments.is_empty() + || request.backend_job.is_some() + || request.subjektiv_attached + || request.worker_observation_enabled + || !request.worker_observation_grants.is_empty() + || (install && request.initial_input.is_some()) + { + return Err(reject( + "unsupported capabilities, attachments, or initial input", + )); + } + if install + && (request.create_fingerprint.trim().is_empty() + || request.config_bundle.is_none() + || !matches!( + request.profile_source, + ProfileSourceArchiveSource::WorkspaceConfig { .. } + )) + { + return Err(reject( + "fresh config/profile references and create fingerprint are required", + )); + } + Ok(()) +} + +fn check_empty_artifacts(root: &Path) -> Result<(), RuntimeError> { + for entry in io(fs::read_dir(root))? { + let entry = io(entry)?; + let metadata = io(entry.metadata())?; + if metadata.is_dir() { + check_empty_artifacts(&entry.path())?; + } else if entry.file_name() != ".aggregate.lock" || metadata.len() != 0 { + return Err(reject("Session artifacts are unsupported")); + } + } + Ok(()) +} + +fn write_new(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> { + let mut file = io(OpenOptions::new().write(true).create_new(true).open(path))?; + io(file.write_all(bytes))?; + io(file.sync_all()) +} + +// A streaming validation visitor, not a generic document available to callers. +struct PortableJson; +impl<'de> Deserialize<'de> for PortableJson { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = PortableJson; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("portable fixture JSON") + } + fn visit_bool(self, _: bool) -> Result { + Ok(PortableJson) + } + fn visit_i64(self, _: i64) -> Result { + Ok(PortableJson) + } + fn visit_u64(self, _: u64) -> Result { + Ok(PortableJson) + } + fn visit_f64(self, _: f64) -> Result { + Ok(PortableJson) + } + fn visit_unit(self) -> Result { + Ok(PortableJson) + } + fn visit_str(self, value: &str) -> Result { + let lower = value.to_ascii_lowercase(); + if [ + "://", + "private key", + "bearer ", + "sk-", + "api_key", + "access_token", + "refresh_token", + "private_key", + "authorization", + ] + .iter() + .any(|marker| lower.contains(marker)) + || value + .split_whitespace() + .any(|word| word.starts_with('/') || word.contains(":\\")) + { + return Err(E::custom("nonportable or sensitive fixture material")); + } + Ok(PortableJson) + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + while seq.next_element::()?.is_some() {} + Ok(PortableJson) + } + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut keys = std::collections::BTreeSet::new(); + while let Some(key) = map.next_key::()? { + // A duplicate key can hide discarded data even inside an + // otherwise opaque extension payload. Never retain it. + if !keys.insert(key.clone()) { + return Err(serde::de::Error::custom("duplicate fixture JSON field")); + } + serde::de::Visitor::visit_str::(Visitor, &key)?; + map.next_value::()?; + } + Ok(PortableJson) + } + } + deserializer.deserialize_any(Visitor) + } +} diff --git a/crates/worker-runtime/src/test_support_tests.rs b/crates/worker-runtime/src/test_support_tests.rs new file mode 100644 index 000000000..203434258 --- /dev/null +++ b/crates/worker-runtime/src/test_support_tests.rs @@ -0,0 +1,885 @@ +use super::*; +use crate::auth::RuntimeIdentityMaterial; +use crate::catalog::{ProfileSelector, WorkspaceApiRef}; +use crate::profile_archive::ProfileSourceGraphSummary; +use session_store::{LogEntry, Store}; +use sha2::{Digest, Sha256}; + +const RUNTIME: &str = "fixture-runtime"; +const WORKSPACE: &str = "fixture-workspace"; +const NEW_URL: &str = "http://127.0.0.1:34567"; + +#[derive(Serialize)] +#[serde(untagged)] +enum ReceiptFixture { + Valid(T), + Malformed(u8), +} + +#[derive(Default, Serialize)] +struct CheckpointFixture { + next_activation_sequence: u64, + activating: Option, + activating_notification: Vec, + pending: Vec, + pending_notifications: Vec, + receipts: Vec>, + notification_receipts: Vec>, + invocation_receipts: Vec, + invocation_recovery_blocked: bool, +} + +fn settled_checkpoint() -> CheckpointFixture { + CheckpointFixture { + next_activation_sequence: 2, + receipts: vec![ReceiptFixture::Valid(SubmissionReceipt { + submission_request_id: "completed-submission-request".into(), + source_namespace: "fixture-source".into(), + submission_id: "completed-submission".into(), + payload_digest: "fixture-payload-digest".into(), + disposition: protocol::SubmissionDisposition::Started, + })], + notification_receipts: vec![ReceiptFixture::Valid(NotificationReceipt { + notification_request_id: "completed-notification-request".into(), + source_namespace: "fixture-source".into(), + payload_digest: "fixture-notification-digest".into(), + })], + ..Default::default() + } +} + +fn checkpoint_extension(checkpoint: &CheckpointFixture) -> session_store::SessionExtension { + session_store::SessionExtension::new( + "worker.pending_activations.v1", + serde_json::to_value(checkpoint).unwrap(), + ) +} + +fn worker_id() -> WorkerId { + "01a1657d-b762-7791-87ae-a49f28cadbce".parse().unwrap() +} + +fn resolved() -> manifest::WorkerManifest { + let mut config = manifest::WorkerManifestConfig::resolution_defaults(); + config.worker.name = Some(runtime_worker_name(worker_id())); + manifest::WorkerManifest::try_from(config).unwrap() +} + +fn request() -> CreateWorkerRequest { + CreateWorkerRequest { + worker_id: worker_id(), + create_fingerprint: "new-create-fingerprint".into(), + profile: ProfileSelector::Builtin("default".into()), + display_name: Some("fixture".into()), + profile_source: ProfileSourceArchiveSource::WorkspaceConfig { + archive: ProfileSourceArchiveRef { + id: "new-profile".into(), + digest: "new-profile-digest".into(), + size_bytes: 1, + source_graph: ProfileSourceGraphSummary { + source_count: 1, + total_source_bytes: 1, + entrypoints: BTreeMap::from([("builtin:default".into(), "default.dcl".into())]), + import_count: 0, + }, + }, + }, + config_bundle: Some(ConfigBundleRef { + id: "new-config".into(), + digest: "new-config-digest".into(), + }), + workspace_api: Some(WorkspaceApiRef { + workspace_id: WORKSPACE.into(), + base_url: NEW_URL.into(), + }), + initial_input: None, + workdir_attachment_requests: Vec::new(), + workdir_attachments: Vec::new(), + worker_observation_enabled: false, + worker_observation_grants: Vec::new(), + memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: WORKSPACE.into(), + language: "en".into(), + }), + subjektiv_attached: false, + backend_job: None, + } +} + +fn initialize(root: &Path, runtime_id: &str, workspace_id: &str, url: &str) { + #[derive(Serialize)] + struct Auth { + identity: RuntimeIdentityMaterial, + workspace_issuers: Vec, + } + let issuer = RuntimeIdentityMaterial::generate("fixture-issuer").unwrap(); + let digest = Sha256::digest(crate::auth::decode_public_key(&issuer.public_key).unwrap()); + let fingerprint = format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let trust = WorkspaceIssuerTrustRecord::from_bundle( + server_api::WorkspacePublicIdentityBundle { + workspace_id: workspace_id.into(), + backend_url: url.into(), + key_id: "fixture-issuer".into(), + algorithm: "ed25519".into(), + public_key: issuer.public_key, + public_key_fingerprint: fingerprint, + }, + "fixture-trust".into(), + 100, + ) + .unwrap(); + fs::create_dir_all(root).unwrap(); + fs::write( + root.join("auth.toml"), + toml::to_string(&Auth { + identity: RuntimeIdentityMaterial::generate(runtime_id).unwrap(), + workspace_issuers: vec![trust], + }) + .unwrap(), + ) + .unwrap(); +} + +fn record(request: CreateWorkerRequest) -> PersistedWorkerRecord { + PersistedWorkerRecord { + restore_guard: crate::runtime::RestoreGuard::default(), + worker_ref: WorkerRef::new(request.worker_id), + worker_id: request.worker_id, + profile: request.profile.clone(), + display_name: request.display_name.clone(), + profile_source: request.profile_source.reference(), + config_bundle: request.config_bundle.clone(), + created_at_ms: Some(123456), + status: WorkerStatus::Stopped, + last_finished_submission_request_id: Some("old-submission-operation".into()), + workspace_id: Some(WORKSPACE.into()), + workdir_attachments: vec![], + logical_workdir_attachments: vec![], + execution_state: PersistedWorkerExecutionState::Available(PersistedWorkerExecution { + request, + binding: Some(PersistedWorkerExecutionBinding {}), + restore_intent: WorkerRestoreIntent::Explicit, + }), + } +} + +fn source() -> (tempfile::TempDir, PathBuf, StoppedWorkerTemplate) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + initialize(&root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456"); + drop( + crate::Runtime::with_fs_store(FsRuntimeStoreOptions::new(&root).with_runtime_id(RUNTIME)) + .unwrap(), + ); + let opened = FsRuntimeStore::open_or_create(root.clone(), RUNTIME).unwrap(); + let mut old = request(); + old.workspace_api.as_mut().unwrap().base_url = "http://127.0.0.1:23456".into(); + old.config_bundle.as_mut().unwrap().id = "old-config".into(); + opened.store.write_worker_record(&record(old)).unwrap(); + let worker = opened.store.worker_dir(&worker_id()); + let session_id = "01a1657d-b800-7777-8888-111111111111".parse().unwrap(); + let segment_id = "01a1657d-b818-77c1-bd73-176172a4bd82".parse().unwrap(); + let mut metadata = WorkerMetadata::new( + runtime_worker_name(worker_id()), + Some(WorkerActiveSegmentRef { + session_id, + segment_id: Some(segment_id), + }), + ) + .with_workspace_id(WORKSPACE) + .with_workspace_root(PathBuf::from("/old/host/path")); + metadata.resolved_manifest_snapshot = + Some(manifest::write_persisted_worker_manifest_snapshot(&resolved()).unwrap()); + atomic_write_json( + &worker.join(WORKER_METADATA_FILE), + &metadata, + "test metadata", + ) + .unwrap(); + let sessions = WorkerSessionStore::new(worker.join("session")).unwrap(); + let history = session_store::LoggedHistoryEntry { + item: session_store::LoggedItem::Message { + role: session_store::LoggedRole::Assistant, + content: vec![session_store::LoggedContentPart::Text { + text: "artificial fixture reply".into(), + }], + }, + metadata: session_store::LoggedSessionHistoryMetadata { + entry_id: session_store::LoggedSessionHistoryEntryId("fixed-entry-id".into()), + origin: session_store::LoggedSessionHistoryOrigin::ModelOutput { + worker: session_store::LoggedWorkerSubject { + workspace_id: Some(WORKSPACE.into()), + runtime_id: Some(RUNTIME.into()), + worker_id: worker_id().to_string(), + }, + }, + derivation: None, + input_metadata: None, + }, + }; + sessions + .create_segment( + session_id, + segment_id, + &[ + LogEntry::AnnotatedSegmentStart { + ts: 123, + session_id, + system_prompt: Some("Harmless artificial fixture instructions.".into()), + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }, + LogEntry::Invoke { + ts: 124, + trigger: protocol::InvokeKind::UserSend, + }, + LogEntry::AnnotatedUserInput { + ts: 124, + segments: vec![protocol::Segment::text( + "Harmless artificial fixture input.", + )], + extensions: vec![checkpoint_extension(&settled_checkpoint())], + history: vec![session_store::LoggedHistoryEntry { + item: session_store::LoggedItem::Message { + role: session_store::LoggedRole::User, + content: vec![session_store::LoggedContentPart::Text { + text: "Harmless artificial fixture input.".into(), + }], + }, + metadata: session_store::LoggedSessionHistoryMetadata { + entry_id: session_store::LoggedSessionHistoryEntryId( + "fixed-user-id".into(), + ), + origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction { + operation_id: None, + }, + derivation: None, + input_metadata: None, + }, + }], + }, + LogEntry::AnnotatedAssistantItem { + ts: 125, + entry: history, + }, + LogEntry::LlmUsage { + ts: 125, + history_len: 2, + input_total_tokens: 1, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 1, + }, + LogEntry::TurnEnd { + ts: 126, + turn_count: 1, + }, + LogEntry::RunCompleted { + entry_id: Some(session_store::LoggedSessionHistoryEntryId( + "fixed-terminal-id".into(), + )), + ts: 126, + interrupted: false, + result: agen::EngineResult::Finished, + active_run_turn_count: None, + }, + ], + ) + .unwrap(); + fs::create_dir_all(worker.join("runs/old-run")).unwrap(); + fs::write(worker.join("runs/old-run/private.log"), "not exported").unwrap(); + drop(opened); + let template = StoppedWorkerTemplate::extract(&worker).unwrap(); + (temp, worker, template) +} + +fn destination(template: &StoppedWorkerTemplate) -> (tempfile::TempDir, FsRuntimeStoreOptions) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + initialize( + &root, + template.runtime_id(), + template.workspace_id(), + NEW_URL, + ); + ( + temp, + FsRuntimeStoreOptions::new(root).with_runtime_id(template.runtime_id()), + ) +} + +fn assert_fresh(options: &FsRuntimeStoreOptions) { + assert_eq!(fs::read_dir(&options.root).unwrap().count(), 1); + assert!(options.root.join("auth.toml").is_file()); +} + +#[test] +fn extraction_install_roundtrip_preserves_bytes_and_is_inactive() { + let (_source, worker, template) = source(); + let original_worker = fs::read(worker.join(WORKER_FILE)).unwrap(); + let original_metadata = fs::read(worker.join(WORKER_METADATA_FILE)).unwrap(); + let exported = serde_json::to_vec(&template).unwrap(); + let exported_text = String::from_utf8(exported.clone()).unwrap(); + for forbidden in [ + "old-config", + "old-submission-operation", + "restore_guard", + "private_key", + "http://", + "/old/host", + "private.log", + "resolved_manifest_snapshot", + ] { + assert!(!exported_text.contains(forbidden)); + } + let template: StoppedWorkerTemplate = serde_json::from_slice(&exported).unwrap(); + let (_target, options) = destination(&template); + let auth_before = fs::read(options.root.join("auth.toml")).unwrap(); + template.install(&options, request(), &resolved()).unwrap(); + assert_eq!( + fs::read(options.root.join("auth.toml")).unwrap(), + auth_before + ); + assert_eq!(fs::read(worker.join(WORKER_FILE)).unwrap(), original_worker); + assert_eq!( + fs::read(worker.join(WORKER_METADATA_FILE)).unwrap(), + original_metadata + ); + let installed = options + .root + .join(WORKERS_DIR) + .join(template.worker_id().to_string()); + assert_eq!( + fs::read(installed.join("session/session.json")).unwrap(), + template.session_manifest + ); + assert_eq!( + fs::read(installed.join(format!("session/segments/{}.jsonl", template.segment_id()))) + .unwrap(), + template.segment_jsonl + ); + assert!(!installed.join("runs").exists()); + let metadata: WorkerMetadata = + decode(&fs::read(installed.join(WORKER_METADATA_FILE)).unwrap()).unwrap(); + assert_eq!(metadata.active.unwrap().session_id, template.session_id()); + assert!(metadata.workspace_root.is_none() && metadata.resolved_manifest_snapshot.is_some()); + + let opened = FsRuntimeStore::open_or_create(options.root.clone(), RUNTIME).unwrap(); + let state = opened.state.unwrap(); + assert!(state.diagnostics.is_empty()); + let saved = state.workers.get(&worker_id()).unwrap(); + assert_eq!(saved.status, WorkerStatus::Stopped); + assert!(saved.last_finished_submission_request_id.is_none()); + let PersistedWorkerExecutionState::Available(execution) = &saved.execution_state else { + panic!("not available") + }; + assert_eq!(execution.restore_intent, WorkerRestoreIntent::Explicit); + assert!(execution.binding.is_none()); + assert_eq!(execution.request, request()); + // Fresh restore guard, no operation owner/receipt history. + assert_ne!( + serde_json::to_vec(&saved.restore_guard).unwrap(), + serde_json::to_vec( + &decode::(&original_worker) + .unwrap() + .restore_guard + ) + .unwrap() + ); + let guard: GuardState = decode(&serde_json::to_vec(&saved.restore_guard).unwrap()).unwrap(); + assert!(guard.active_request_id.is_none() && guard.owners.is_empty()); + drop(opened.store); + + struct NoExecution; + impl crate::execution::WorkerExecutionBackend for NoExecution { + fn backend_id(&self) -> &str { + "fixture-no-execution" + } + fn spawn_worker( + &self, + _: crate::execution::WorkerExecutionSpawnRequest, + ) -> crate::execution::WorkerExecutionSpawnResult { + panic!("fixture executed") + } + fn dispatch_input( + &self, + _: &WorkerRef, + _: crate::interaction::WorkerInput, + ) -> crate::execution::WorkerExecutionResult { + panic!("fixture submitted") + } + fn restore_worker( + &self, + _: crate::execution::WorkerExecutionRestoreRequest, + ) -> crate::execution::WorkerExecutionSpawnResult { + panic!("fixture auto-restored") + } + } + let runtime = + crate::Runtime::with_fs_store_and_execution_backend(options.clone(), Arc::new(NoExecution)) + .unwrap(); + assert_eq!(runtime.list_workers().unwrap().len(), 1); + assert_eq!(runtime.summary().unwrap().active_worker_count, 0); + assert_eq!( + runtime + .worker_detail(&WorkerRef::new(worker_id())) + .unwrap() + .status, + WorkerStatus::Stopped + ); + drop(runtime); + let runtime = crate::Runtime::with_fs_store(options).unwrap(); + assert_eq!( + runtime.list_workers().unwrap()[0].status, + WorkerStatus::Stopped + ); +} + +#[test] +fn install_rejects_schema_identity_input_and_overwrite() { + let (_source, _, template) = source(); + for change in [ + "template", + "runtime-schema", + "session-schema", + "session-id", + "segment-id", + "runtime-id", + "workspace-id", + "worker-id", + "origin-id", + "duplicate-id", + "truncated", + ] { + let (_target, options) = destination(&template); + let mut invalid = template.clone(); + match change { + "template" => invalid.template_version += 1, + "runtime-schema" => invalid.runtime_schema_version += 1, + "session-schema" => { + invalid.session_manifest = format!( + "{{\"schema_version\":999,\"session_id\":\"{}\"}}", + invalid.session_id + ) + .into_bytes() + } + "session-id" => invalid.session_id = uuid::Uuid::now_v7(), + "segment-id" => invalid.segment_id = uuid::Uuid::nil(), // no external attestation: valid new Segment ID is otherwise portable + "runtime-id" => invalid.runtime_id = "other-runtime".into(), + "workspace-id" => invalid.workspace_id = "other-workspace".into(), + "worker-id" => invalid.worker_id = WorkerId::now_v7(), + "origin-id" => { + invalid.segment_jsonl = String::from_utf8(invalid.segment_jsonl) + .unwrap() + .replace(RUNTIME, "other-runtime") + .into_bytes() + } + "duplicate-id" => { + let last = invalid + .segment_jsonl + .split_inclusive(|b| *b == b'\n') + .last() + .unwrap() + .to_vec(); + invalid.segment_jsonl.extend(last); + } + "truncated" => { + invalid.segment_jsonl.pop(); + } + _ => unreachable!(), + } + assert!( + invalid.install(&options, request(), &resolved()).is_err(), + "accepted {change}" + ); + assert_fresh(&options); + } + let (_target, options) = destination(&template); + let mut launch = request(); + launch.initial_input = Some(crate::interaction::WorkerInput::user("do not execute")); + assert!(template.install(&options, launch, &resolved()).is_err()); + assert_fresh(&options); + template.install(&options, request(), &resolved()).unwrap(); + let worker_path = options + .root + .join(WORKERS_DIR) + .join(worker_id().to_string()) + .join(WORKER_FILE); + let before = fs::read(&worker_path).unwrap(); + assert!(template.install(&options, request(), &resolved()).is_err()); + assert_eq!(fs::read(worker_path).unwrap(), before); +} + +#[test] +fn rejects_uninitialized_untrusted_live_and_partial_destination() { + let (_source, _, template) = source(); + let (_target, options) = destination(&template); + let held = acquire_runtime_store_owner_lock(&options.root).unwrap(); + assert!(template.install(&options, request(), &resolved()).is_err()); + assert_fresh(&options); + drop(held); + initialize(&options.root, "wrong-runtime", WORKSPACE, NEW_URL); + assert!(template.install(&options, request(), &resolved()).is_err()); + initialize(&options.root, RUNTIME, WORKSPACE, "http://127.0.0.1:23456"); + assert!(template.install(&options, request(), &resolved()).is_err()); + fs::remove_file(options.root.join("auth.toml")).unwrap(); + assert!(template.install(&options, request(), &resolved()).is_err()); + initialize(&options.root, RUNTIME, WORKSPACE, NEW_URL); + fs::create_dir(options.root.join(WORKERS_DIR)).unwrap(); + assert!(template.install(&options, request(), &resolved()).is_err()); +} + +#[test] +fn extraction_rejects_nonstopped_pending_schema_and_metadata_mismatch() { + for change in [ + "running", + "pending", + "guard-pending", + "attachment", + "schema", + "worker-id", + "metadata-workspace", + "metadata-session", + "artifact", + "secret", + "url", + "path", + ] { + let (_source, worker, _) = source(); + let mut aggregate: WorkerAggregateRecord = + decode(&fs::read(worker.join(WORKER_FILE)).unwrap()).unwrap(); + match change { + "running" => aggregate.status = WorkerStatus::Running, + "pending" => { + aggregate.execution_state = WorkerExecutionStateRecord::ReconciliationRequired( + PersistedWorkerLifecycleOperation::Restore(PersistedWorkerRestoreOperation { + operation_id: WorkerLifecycleOperationId::new(), + mode: PersistedWorkerRestoreMode::Explicit, + request: request(), + binding: None, + last_settled_status: WorkerStatus::Stopped, + }), + ) + } + "guard-pending" => aggregate.restore_guard = serde_json::from_str(r#"{"observation_token":"old-token","active_request_id":"pending-restore","owners":{}}"#).unwrap(), + "attachment" => aggregate.logical_workdir_attachments.push(LogicalWorkdirAttachment { + alias: workdir::WorkdirAttachmentAlias::new("source").unwrap(), + working_directory_id: "old-workdir".into(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + }), + "schema" => aggregate.schema_version += 1, + "worker-id" => aggregate.worker_id = WorkerId::now_v7(), + "metadata-workspace" | "metadata-session" => { + let mut metadata: WorkerMetadata = + decode(&fs::read(worker.join(WORKER_METADATA_FILE)).unwrap()).unwrap(); + if change == "metadata-workspace" { + metadata.workspace_id = Some("wrong-workspace".into()); + } else { + metadata.active.as_mut().unwrap().session_id = uuid::Uuid::now_v7(); + } + atomic_write_json( + &worker.join(WORKER_METADATA_FILE), + &metadata, + "test mismatch", + ) + .unwrap(); + } + "artifact" => { + fs::create_dir_all(worker.join("session/artifacts/paste")).unwrap(); + fs::write( + worker.join("session/artifacts/paste/payload"), + "unsupported", + ) + .unwrap(); + } + "secret" | "url" | "path" => { + let segment = fs::read_dir(worker.join("session/segments")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let replacement = match change { + "secret" => "Bearer secret", + "url" => "https://old-host.invalid", + _ => "/old/host/path", + }; + let text = fs::read_to_string(&segment) + .unwrap() + .replace("artificial fixture reply", replacement); + fs::write(segment, text).unwrap(); + } + _ => unreachable!(), + } + atomic_write_json(&worker.join(WORKER_FILE), &aggregate, "test aggregate").unwrap(); + assert!( + StoppedWorkerTemplate::extract(&worker).is_err(), + "accepted {change}" + ); + } + let (_source, worker, _) = source(); + let held = + acquire_runtime_store_owner_lock(worker.parent().unwrap().parent().unwrap()).unwrap(); + assert!(StoppedWorkerTemplate::extract(&worker).is_err()); + drop(held); +} + +#[test] +fn fresh_manifest_and_memory_settings_are_required() { + let (_source, _, template) = source(); + let (_target, options) = destination(&template); + let mut manifest = resolved(); + manifest.worker.name = "wrong-worker".into(); + assert!(template.install(&options, request(), &manifest).is_err()); + let mut binding = request(); + binding.memory_settings = None; + assert!(template.install(&options, binding, &resolved()).is_err()); + let mut binding = request(); + binding.memory_settings.as_mut().unwrap().workspace_id = "other-workspace".into(); + assert!(template.install(&options, binding, &resolved()).is_err()); + assert_fresh(&options); +} + +#[test] +fn settled_checkpoints_preserve_receipts_but_refuse_pending_work() { + let mut checkpoint = settled_checkpoint(); + assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_ok()); + checkpoint.pending.push(1); + assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err()); + checkpoint.pending.clear(); + checkpoint.activating = Some(1); + assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err()); + checkpoint.activating = None; + checkpoint.invocation_receipts.push(1); + assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err()); + checkpoint.invocation_receipts.clear(); + checkpoint.invocation_recovery_blocked = true; + assert!(validate_settled_activation(&checkpoint_extension(&checkpoint)).is_err()); +} + +// Deliberately corrupt only test-owned synthetic source files, never the lab. +fn assert_rejected_session_bytes(worker: &Path, template: &StoppedWorkerTemplate, bytes: Vec) { + let path = worker.join(format!("session/segments/{}.jsonl", template.segment_id())); + fs::write(path, &bytes).unwrap(); + let extraction_error = StoppedWorkerTemplate::extract(worker) + .err() + .expect("invalid source accepted"); + assert!( + !extraction_error + .to_string() + .contains("synthetic-discarded-material") + ); + let (_target, options) = destination(template); + let mut invalid = template.clone(); + invalid.segment_jsonl = bytes; + let installation_error = invalid + .install(&options, request(), &resolved()) + .unwrap_err(); + assert!( + !installation_error + .to_string() + .contains("synthetic-discarded-material") + ); + assert_fresh(&options); +} + +fn synthetic_logs(template: &StoppedWorkerTemplate) -> Vec { + template + .segment_jsonl + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()) + .collect() +} + +fn encode_synthetic_logs(logs: &[LogEntry]) -> Vec { + let mut bytes = Vec::new(); + for log in logs { + serde_json::to_writer(&mut bytes, log).unwrap(); + bytes.push(b'\n'); + } + bytes +} + +#[tokio::test] +async fn missing_system_prompt_matches_worker_restore_rejection() { + let (_source, worker_dir, template) = source(); + let mut logs = synthetic_logs(&template); + assert!(session_store::collect_state(&logs).system_prompt.is_some()); + let LogEntry::AnnotatedSegmentStart { system_prompt, .. } = &mut logs[0] else { + panic!("missing seed") + }; + *system_prompt = None; + assert_rejected_session_bytes(&worker_dir, &template, encode_synthetic_logs(&logs)); + // The owning Worker constructor rejects this before model-client creation, + // allocation, or execution. Do not start a controller or submit any input. + let store = session_store::CombinedStore::new( + WorkerSessionStore::open_read_only(worker_dir.join("session")).unwrap(), + session_store::WorkerAggregateStore::open_read_only(&worker_dir, template.worker_name()) + .unwrap(), + ); + let restored = worker::Worker::restore_from_manifest( + template.session_id(), + template.segment_id(), + resolved(), + store, + worker::PromptCatalogSource::builtins_only(), + ) + .await; + assert!(matches!( + restored, + Err(worker::WorkerError::SegmentSystemPromptMissing { .. }) + )); +} + +#[test] +fn malformed_submission_and_notification_receipts_fail_extraction_and_install() { + let (_source, worker, template) = source(); + for notification in [false, true] { + let mut checkpoint = settled_checkpoint(); + if notification { + checkpoint.notification_receipts = + vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)]; + } else { + checkpoint.receipts = vec![ReceiptFixture::Malformed(1), ReceiptFixture::Malformed(2)]; + } + let mut logs = synthetic_logs(&template); + for log in &mut logs { + if let LogEntry::AnnotatedUserInput { extensions, .. } = log { + *extensions = vec![checkpoint_extension(&checkpoint)]; + } + } + assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs)); + } +} + +#[test] +fn unknown_fields_at_every_retained_record_depth_are_rejected_without_secret_diagnostics() { + let (_source, worker, template) = source(); + let original = String::from_utf8(template.segment_jsonl.clone()).unwrap(); + // These values are harmless synthetic markers, not lab/private material. + // The compatibility decoder normally discards many of these fields. + let locations = [ + r#""kind":"annotated_segment_start""#, + r#""kind":"invoke""#, + r#""kind":"annotated_user_input""#, + r#""kind":"annotated_assistant_item""#, + r#""kind":"llm_usage""#, + r#""kind":"turn_end""#, + r#""kind":"run_completed""#, + r#""max_tokens":null"#, + r#""kind":"message","role":"assistant""#, + r#""kind":"text","text":"artificial fixture reply""#, + r#""kind":"text","content":"Harmless artificial fixture input.""#, + r#""entry_id":"fixed-entry-id""#, + r#""kind":"model_output""#, + r#""worker_id":"01a1657d-b762-7791-87ae-a49f28cadbce""#, + r#""domain":"worker.pending_activations.v1""#, + r#""next_activation_sequence":2"#, + r#""submission_request_id":"completed-submission-request""#, + r#""notification_request_id":"completed-notification-request""#, + ]; + for location in locations { + assert!(original.contains(location)); + for extra in [ + r#", "reasoning":{"text":"synthetic-discarded-material"}"#, + r#", "encrypted_content":"synthetic-discarded-material""#, + ] { + let altered = original.replacen(location, &format!("{location}{extra}"), 1); + assert_rejected_session_bytes(&worker, &template, altered.into_bytes()); + } + } + let duplicate = original.replacen( + r#""role":"assistant""#, + r#""role":"assistant","role":"assistant""#, + 1, + ); + assert_rejected_session_bytes(&worker, &template, duplicate.into_bytes()); +} + +#[test] +fn opaque_feature_payloads_cannot_bypass_strict_session_validation() { + let (_source, worker, template) = source(); + let mut logs = synthetic_logs(&template); + for log in &mut logs { + if let LogEntry::AnnotatedUserInput { history, .. } = log { + if let session_store::LoggedItem::Message { role, .. } = &mut history[0].item { + *role = session_store::LoggedRole::System; + } + history[0].metadata.input_metadata = Some( + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::Retain, + }, + Some( + session_store::FeatureInputPayload::encode( + "fixture.test", + 1, + &BTreeMap::from([("reasoning", "harmless synthetic payload")]), + ) + .unwrap(), + ), + None, + ) + .unwrap(), + ); + } + } + assert_rejected_session_bytes(&worker, &template, encode_synthetic_logs(&logs)); +} + +#[cfg(unix)] +#[test] +fn refuses_source_and_destination_symlinks() { + let (_source, worker, template) = source(); + let (_target, options) = destination(&template); + std::os::unix::fs::symlink(worker.join(WORKER_FILE), options.root.join("linked")).unwrap(); + assert!(template.install(&options, request(), &resolved()).is_err()); + fs::remove_file(options.root.join("linked")).unwrap(); + std::os::unix::fs::symlink(worker.join(WORKER_FILE), worker.join("runs/link")).unwrap(); + assert!(StoppedWorkerTemplate::extract(&worker).is_err()); +} + +/// Opt-in only: source path supplied explicitly; assertions never display content. +#[test] +#[ignore = "requires the approved artificial lab aggregate via YOI_STOPPED_FIXTURE_SOURCE"] +fn approved_lab_extract_install() { + let path = PathBuf::from( + std::env::var_os("YOI_STOPPED_FIXTURE_SOURCE").expect("explicit source required"), + ); + let template = StoppedWorkerTemplate::extract(&path).unwrap(); + let (_target, options) = destination(&template); + let mut bind = request(); + bind.worker_id = template.worker_id(); + bind.workspace_api.as_mut().unwrap().workspace_id = template.workspace_id().into(); + bind.memory_settings.as_mut().unwrap().workspace_id = template.workspace_id().into(); + let mut manifest = resolved(); + manifest.worker.name = template.worker_name(); + template.install(&options, bind, &manifest).unwrap(); + let installed = options + .root + .join(WORKERS_DIR) + .join(template.worker_id().to_string()); + for relative in [ + "session/session.json".to_string(), + format!("session/segments/{}.jsonl", template.segment_id()), + ] { + assert!( + fs::read(path.join(&relative)).unwrap() == fs::read(installed.join(relative)).unwrap() + ); + } + let runtime = crate::Runtime::with_fs_store(options).unwrap(); + assert_eq!(runtime.list_workers().unwrap().len(), 1); + assert_eq!(runtime.summary().unwrap().active_worker_count, 0); +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 695a03379..87c0f676c 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1682,6 +1682,15 @@ fn bind_workspace_memory_settings( Ok(()) } +/// Read-only validation shared with the offline, feature-gated fixture installer. +#[cfg(feature = "test-support")] +pub(crate) fn validate_fixture_restore_metadata( + metadata: &session_store::WorkerMetadata, + request: &CreateWorkerRequest, +) -> Result<(), String> { + ProfileRuntimeWorkerFactory::manifest_for_restore(metadata, request).map(|_| ()) +} + fn validate_worker_memory_settings( manifest: &manifest::WorkerManifest, request: &CreateWorkerRequest, diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index b561ca4eb..0879f2b07 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -10,6 +10,10 @@ autobins = false name = "yoi-server" path = "src/main.rs" +[features] +# Offline fixture preparation only; build product binaries without this feature. +test-support = [] + [dependencies] api-macros = { workspace = true, features = ["axum"] } async-trait.workspace = true diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index dd117ae7b..c4a95e8c0 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -28,6 +28,8 @@ pub mod runtime_subscription; pub mod server; pub mod skills; pub mod store; +#[cfg(feature = "test-support")] +pub use store::fixture_support as test_support; pub mod subjektiv; #[cfg(test)] mod ticket_browse_performance_tests; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index d742f0ec6..f12c54334 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -2589,6 +2589,10 @@ pub struct SqliteWorkspaceStore { db_executor: crate::db_executor::DbExecutor, } +#[cfg(feature = "test-support")] +#[path = "test_support.rs"] +pub mod fixture_support; + impl SqliteWorkspaceStore { pub fn open(path: impl AsRef) -> Result { let conn = Connection::open(path)?; @@ -2900,6 +2904,33 @@ impl SqliteWorkspaceStore { request_fingerprint: &str, singleton_key: Option<&str>, current_memory_settings: &WorkspaceMemorySettingsRecord, + ) -> Result { + self.reserve_worker_create_with( + workspace_id, + runtime_id, + allocation_key, + request_fingerprint, + singleton_key, + current_memory_settings, + |_| Ok(None), + |_, _| Ok(()), + ) + } + + // Keep reservation validation, Memory binding and writes in one transaction. The + // nondefault test-support module supplies fixed identity / offline catalog hooks; + // ordinary creation always supplies a newly generated ID and no completion hook. + #[allow(clippy::too_many_arguments)] + fn reserve_worker_create_with( + &self, + workspace_id: &str, + runtime_id: &str, + allocation_key: &str, + request_fingerprint: &str, + singleton_key: Option<&str>, + current_memory_settings: &WorkspaceMemorySettingsRecord, + prepare: impl FnOnce(&rusqlite::Transaction<'_>) -> Result>, + complete: impl FnOnce(&rusqlite::Transaction<'_>, &WorkerCreateReservation) -> Result<()>, ) -> Result { if allocation_key.trim().is_empty() || request_fingerprint.trim().is_empty() { return Err(Error::InvalidInput( @@ -2912,6 +2943,7 @@ impl SqliteWorkspaceStore { validate_workspace_memory_settings_record(current_memory_settings, workspace_id)?; self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let fixed_worker_id = prepare(&tx)?; let existing = tx .query_row( "SELECT worker_id, runtime_id, request_fingerprint, create_fingerprint, \ @@ -3094,7 +3126,7 @@ impl SqliteWorkspaceStore { validate_workspace_memory_settings_snapshot(&snapshot, workspace_id)?; let create_fingerprint = bound_worker_create_fingerprint(request_fingerprint, &snapshot); - let worker_id = WorkerId::now_v7(); + let worker_id = fixed_worker_id.unwrap_or_else(WorkerId::now_v7); let worker_id_text = worker_id.to_string(); let now = chrono::Utc::now().to_rfc3339(); let singleton_generation = match singleton_owner.as_ref() { @@ -3183,13 +3215,15 @@ impl SqliteWorkspaceStore { )); } } - tx.commit()?; - Ok(WorkerCreateReservation { + let reservation = WorkerCreateReservation { worker_id, create_fingerprint, memory_settings: snapshot, singleton, - }) + }; + complete(&tx, &reservation)?; + tx.commit()?; + Ok(reservation) }) } diff --git a/crates/workspace-server/src/test_support.rs b/crates/workspace-server/src/test_support.rs new file mode 100644 index 000000000..fcdf63087 --- /dev/null +++ b/crates/workspace-server/src/test_support.rs @@ -0,0 +1,688 @@ +//! Offline Backend fixture preparation, available only with `test-support`. +//! +//! Use a new isolated database and the existing account, Workspace signing-identity / +//! `ControlPlaneStore::create_workspace_bootstrap`, and Runtime binding APIs first. +//! Generate fresh account credentials and signing keys for each environment; no fixture +//! credentials are accepted here. Install Profile configuration through the existing +//! config-source mechanisms before starting the product processes. +//! +//! This module seeds Backend ownership only. It neither writes Runtime storage nor +//! launches a Worker, fabricates Runtime observations, changes authorization, or sends +//! initial input. The caller must separately install the matching stopped Runtime +//! fixture. Build product binaries in a separate Cargo invocation **without** this +//! feature (Cargo features unify within an invocation). + +use super::*; +use crate::hosts::{WorkerSpawnIntent, WorkerSpawnRequest, worker_spawn_create_fingerprint}; +use worker_runtime::catalog::ProfileSelector; + +/// One ordinary, attachment-free Worker from an externally validated fixture. +/// +/// IDs must match the Runtime fixture. `request` is the unresolved canonical create +/// intent, not a Runtime launch request containing credentials or execution grants. +/// The Profile selector must already be installed by the harness. `initial_submit` +/// must be empty: saved Session contents belong to the Runtime fixture, not new input. +/// Singleton/service, Ticket-assigned and attachment-bearing Workers are deliberately +/// unsupported. +pub struct FixedWorkerSeed<'a> { + pub workspace_id: &'a str, + pub runtime_id: &'a str, + pub worker_id: WorkerId, + pub allocation_key: &'a str, + pub request: &'a WorkerSpawnRequest, +} + +/// Insert a fixed Worker catalog entry and completed creation reservation atomically. +/// +/// Uses the normal reservation validators, authoritative Memory snapshot, fingerprint +/// binding, resource-key allocator and immediate transaction. Even an exact repeat is +/// rejected: this is not an upsert/import/recovery API. Existing allocations, Worker +/// identities and residual lifecycle/attachment records are never adopted or repaired. +/// Returns the creation binding needed to check the corresponding Runtime fixture. +pub fn seed_fixed_worker( + store: &SqliteWorkspaceStore, + seed: FixedWorkerSeed<'_>, +) -> Result { + validate_identifier("workspace_id", seed.workspace_id)?; + validate_identifier("runtime_id", seed.runtime_id)?; + let request = seed.request; + if !request.initial_submit.is_empty() { + return Err(Error::InvalidInput( + "fixture Worker cannot contain initial_submit; offline seeding accepts no new input" + .into(), + )); + } + if !matches!(request.intent, WorkerSpawnIntent::WorkspaceCoding) + || request.singleton_key.is_some() + || request.ticket_assignment.is_some() + || request.resolved_subjektiv_attached + || !request.workdir_attachment_requests.is_empty() + || !request.resolved_workdir_attachment_requests.is_empty() + || !request.resolved_workdir_attachments.is_empty() + || request.resolved_config_bundle.is_some() + || request.resolved_workspace_api.is_some() + || request.resolved_memory_settings.is_some() + || request.resolved_worker_observation_enabled + || !request.resolved_worker_observation_grants.is_empty() + || request.resolved_control_operation.is_some() + || request.resolved_execution_grant.is_some() + { + return Err(Error::InvalidInput( + "fixture Worker must be ordinary unresolved create intent without singleton, assignments, attachments or grants".into(), + )); + } + // Builtin selectors already carry their canonical `builtin:` prefix. + let profile = match &request.profile { + ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name, + }; + validate_non_empty("fixture Profile selector", profile)?; + let worker_id = seed.worker_id.to_string(); + let display_name = request + .requested_worker_name + .as_deref() + .unwrap_or(&worker_id); + validate_non_empty("fixture Worker display name", display_name)?; + let fingerprint = worker_spawn_create_fingerprint(request).map_err(Error::InvalidInput)?; + let memory = store.get_workspace_memory_settings(seed.workspace_id)?; + store.reserve_worker_create_with( + seed.workspace_id, + seed.runtime_id, + seed.allocation_key, + &fingerprint, + None, + &memory, + |tx| { + require_seed_target(tx, &seed)?; + Ok(Some(seed.worker_id)) + }, + |tx, reservation| { + let now = chrono::Utc::now().to_rfc3339(); + tx.execute( + "INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, profile, + retention_state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, 'normal', ?6, ?6)", + params![seed.workspace_id, seed.runtime_id, worker_id, display_name, profile, now], + )?; + let changed = tx.execute( + "UPDATE worker_create_reservations SET state = 'created', updated_at = ?4 + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND state = 'reserved'", + params![seed.workspace_id, seed.runtime_id, reservation.worker_id.to_string(), now], + )?; + if changed != 1 { + return Err(Error::Store("fixture creation reservation disappeared".into())); + } + Ok(()) + }, + ) +} + +fn require_seed_target(tx: &rusqlite::Transaction<'_>, seed: &FixedWorkerSeed<'_>) -> Result<()> { + let active: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces w + JOIN workspace_runtime_bindings r ON r.workspace_id = w.workspace_id + JOIN workspace_signing_identities s ON s.workspace_id = w.workspace_id + WHERE w.workspace_id = ?1 AND w.state = 'active' + AND r.runtime_id = ?2 AND r.revoked_at IS NULL + AND r.authentication_mode = 'workspace_identity' AND s.state = 'active')", + params![seed.workspace_id, seed.runtime_id], + |row| row.get(0), + )?; + if !active { + return Err(Error::InvalidInput( + "fixture requires an active bootstrapped Workspace and authenticated Runtime binding" + .into(), + )); + } + let worker_id = seed.worker_id.to_string(); + let allocation_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM worker_create_reservations + WHERE workspace_id = ?1 AND allocation_key = ?2)", + params![seed.workspace_id, seed.allocation_key], + |row| row.get(0), + )?; + if allocation_exists { + return Err(Error::InvalidInput( + "fixture Worker allocation already exists".into(), + )); + } + // Check by WorkerId, not just the proposed Runtime tuple: orphaned and terminal + // ownership is still a collision. These tables are owned by this store schema. + for table in [ + "worker_create_reservations", + "worker_registry", + "worker_registry_observations", + "worker_registry_projection_removals", + "worker_removal_operations", + "worker_singleton_owners", + "worker_workdir_links", + "worker_restore_intents", + "worker_tombstones", + "worker_orphan_diagnostics", + "worker_registry_projection_diagnostics", + "worker_session_archives", + "worker_diagnostics_archives", + "ticket_assignment_worker_tombstones", + ] { + let exists: bool = tx.query_row( + &format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE worker_id = ?1)"), + [&worker_id], + |row| row.get(0), + )?; + if exists { + return Err(Error::InvalidInput(format!( + "fixture Worker identity already exists in {table}" + ))); + } + } + // A resource key without a catalog/reservation must not be silently reused. + let mut statement = tx.prepare( + "SELECT resource_id FROM workspace_resource_keys WHERE resource_kind = 'worker'", + )?; + let resources = statement.query_map([], |row| row.get::<_, String>(0))?; + for resource in resources { + let resource = resource?; + let existing = resource + .is_ascii() + .then(|| parse_worker_resource_id(&resource)) + .flatten() + .ok_or_else(|| { + Error::InvalidInput("unknown fixture Worker resource identity collision".into()) + })?; + if existing.worker_id == worker_id { + return Err(Error::InvalidInput( + "fixture Worker resource identity already exists".into(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hosts::{ + WorkerSpawnAcceptanceRequirement, WorkerSpawnWorkingDirectoryAttachmentRequest, + WorkerSpawnWorkingDirectoryRequest, + }; + use crate::workspace_signing_identity::{ + InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService, + }; + + const WORKSPACE: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c501"; + const RUNTIME: &str = "fixture-runtime"; + const WORKER: &str = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c502"; + + // Exercises the same public bootstrap APIs the offline harness uses. All account + // and key identities are newly generated, even across stores with fixed fixture IDs. + fn bootstrap() -> SqliteWorkspaceStore { + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + let account = AccountRecord { + account_id: format!("fixture-account-{}", Uuid::now_v7()), + kind: "user".into(), + handle: format!("fixture-{}", Uuid::now_v7()), + display_name: "Fixture owner".into(), + created_at: "2026-10-24T00:00:00Z".into(), + updated_at: "2026-10-24T00:00:00Z".into(), + }; + store.upsert_account(&account).unwrap(); + let identities = WorkspaceSigningIdentityService::new( + Arc::new(store.clone()), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let (identity, operation) = identities + .prepare_workspace_creation( + "fixture-bootstrap", + "fixture-bootstrap", + WORKSPACE, + &account.account_id, + ) + .unwrap(); + store + .create_workspace_bootstrap( + &WorkspaceBootstrapRecord { + operation_key: "fixture-bootstrap".into(), + request_fingerprint: "fixture-bootstrap".into(), + workspace: WorkspaceRecord { + workspace_id: WORKSPACE.into(), + owner_account_id: account.account_id, + display_name: "Fixture".into(), + state: "active".into(), + created_at: account.created_at, + updated_at: account.updated_at, + }, + repository: None, + }, + &identity, + &operation, + ) + .unwrap(); + let runtime_key = worker_runtime::auth::RuntimeIdentityMaterial::generate(RUNTIME).unwrap(); + store + .upsert_workspace_runtime_binding( + WorkspaceRuntimeBinding { + workspace_id: WORKSPACE.into(), + runtime_id: RUNTIME.into(), + display_name: "Fixture Runtime".into(), + base_url: "http://127.0.0.1:12345".into(), + public_key: runtime_key.public_key, + public_key_fingerprint: String::new(), + binding_id: String::new(), + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + created_at: "2026-10-24T00:00:00Z".into(), + updated_at: "2026-10-24T00:00:00Z".into(), + revoked_at: None, + }, + false, + ) + .unwrap(); + store + } + + fn request() -> WorkerSpawnRequest { + WorkerSpawnRequest { + intent: WorkerSpawnIntent::WorkspaceCoding, + requested_worker_name: Some("Fixture Worker".into()), + singleton_key: None, + acceptance: WorkerSpawnAcceptanceRequirement::SocketReady, + profile: ProfileSelector::Builtin("builtin:coder".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_control_operation: None, + resolved_execution_grant: None, + } + } + + fn seed(request: &WorkerSpawnRequest) -> FixedWorkerSeed<'_> { + FixedWorkerSeed { + workspace_id: WORKSPACE, + runtime_id: RUNTIME, + worker_id: WORKER.parse().unwrap(), + allocation_key: "fixture-allocation", + request, + } + } + + fn worker() -> RuntimeWorkerRef { + RuntimeWorkerRef::new(RUNTIME, WORKER) + } + + #[test] + fn fixed_ids_are_isolated_and_seed_has_no_runtime_observation() { + let a = bootstrap(); + let b = bootstrap(); + let request = request(); + let first = seed_fixed_worker(&a, seed(&request)).unwrap(); + let second = seed_fixed_worker(&b, seed(&request)).unwrap(); + assert_eq!(first, second); + assert_eq!(first.worker_id.to_string(), WORKER); + assert_eq!(first.memory_settings.workspace_id, WORKSPACE); + assert_eq!(first.memory_settings.language, "English"); + assert!(first.singleton.is_none()); + assert_ne!( + a.get_workspace_signing_identity(WORKSPACE) + .unwrap() + .unwrap() + .public_key, + b.get_workspace_signing_identity(WORKSPACE) + .unwrap() + .unwrap() + .public_key + ); + assert_ne!( + a.get_workspace_runtime_binding(WORKSPACE, RUNTIME) + .unwrap() + .unwrap() + .public_key, + b.get_workspace_runtime_binding(WORKSPACE, RUNTIME) + .unwrap() + .unwrap() + .public_key + ); + for store in [&a, &b] { + assert_eq!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .as_deref(), + Some("W-1") + ); + assert_eq!( + store + .resolve_worker_resource_reference(WORKSPACE, "W-1") + .unwrap(), + Some(worker()) + ); + assert!( + !store + .has_active_worker_create_reservation(WORKSPACE, &worker()) + .unwrap() + ); + let registry = store + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .unwrap(); + assert_eq!(registry.profile.as_deref(), Some("builtin:coder")); + assert_eq!(registry.display_name, "Fixture Worker"); + assert_eq!(registry.retention_state, "normal"); + let projection = store + .with_conn(|conn| read_worker_registry_projection(conn, WORKSPACE, &worker())) + .unwrap() + .unwrap(); + assert!(projection.observation.is_none()); + assert!(projection.job.is_none()); + } + } + + #[test] + fn initial_submit_is_refused_without_registration_or_allocation() { + let store = bootstrap(); + let mut request = request(); + // Even an empty text segment is new input; the vector itself must be empty. + for text in ["new input must not be seeded", ""] { + request.initial_submit = vec![protocol::Segment::text(text)]; + let error = seed_fixed_worker(&store, seed(&request)).unwrap_err(); + assert!( + matches!(error, Error::InvalidInput(ref message) if message.contains("initial_submit")) + ); + assert!( + store + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + assert!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + assert!( + !store + .has_active_worker_create_reservation(WORKSPACE, &worker()) + .unwrap() + ); + } + // The rejected attempts must not consume the allocation or resource counter. + request.initial_submit.clear(); + seed_fixed_worker(&store, seed(&request)).unwrap(); + assert_eq!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .as_deref(), + Some("W-1") + ); + } + + #[test] + fn exact_replay_changed_input_and_cross_runtime_identity_are_refused() { + let store = bootstrap(); + let mut request = request(); + seed_fixed_worker(&store, seed(&request)).unwrap(); + let before = store.get_worker_registry(WORKSPACE, &worker()).unwrap(); + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + request.requested_worker_name = Some("Must not overwrite".into()); + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + let mut new_allocation = seed(&request); + new_allocation.allocation_key = "another-allocation"; + assert!(seed_fixed_worker(&store, new_allocation).is_err()); + let mut same_allocation = seed(&request); + same_allocation.worker_id = WorkerId::now_v7(); + assert!(seed_fixed_worker(&store, same_allocation).is_err()); + assert_eq!( + store.get_worker_registry(WORKSPACE, &worker()).unwrap(), + before + ); + + let other = bootstrap(); + let mut record = before.unwrap(); + record.worker.runtime_id = "different-runtime".into(); + other.upsert_worker_registry(&record).unwrap(); + assert!(seed_fixed_worker(&other, seed(&request)).is_err()); + assert!( + other + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + } + + #[test] + fn reserved_and_orphaned_resource_identities_are_not_adopted() { + let store = bootstrap(); + let request = request(); + let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap(); + let ordinary = store + .reserve_worker_create(WORKSPACE, RUNTIME, "ordinary", "fingerprint", None, &memory) + .unwrap(); + let mut collision = seed(&request); + collision.worker_id = ordinary.worker_id; + assert!(seed_fixed_worker(&store, collision).is_err()); + // Store-level fault setup only: the external harness never writes SQL. + store + .with_conn(|conn| { + allocate_resource_key( + conn, + WORKSPACE, + WorkspaceResourceKind::Worker, + &worker_resource_id(&worker()), + "now", + )?; + Ok(()) + }) + .unwrap(); + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + assert!( + store + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + } + + #[test] + fn unknown_resource_identity_and_retained_removal_fence_are_refused() { + let request = request(); + let malformed = bootstrap(); + malformed + .with_conn(|conn| { + allocate_resource_key( + conn, + WORKSPACE, + WorkspaceResourceKind::Worker, + "unknown-identity", + "now", + )?; + Ok(()) + }) + .unwrap(); + assert!(matches!( + seed_fixed_worker(&malformed, seed(&request)), + Err(Error::InvalidInput(_)) + )); + assert!( + malformed + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + + let removed = bootstrap(); + removed.with_conn(|conn| { + conn.execute( + "INSERT INTO worker_registry_projection_removals(workspace_id, runtime_id, worker_id) VALUES (?1, ?2, ?3)", + params![WORKSPACE, RUNTIME, WORKER], + )?; + Ok(()) + }).unwrap(); + assert!(matches!( + seed_fixed_worker(&removed, seed(&request)), + Err(Error::InvalidInput(_)) + )); + assert!( + removed + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + } + + #[test] + fn unknown_workspace_runtime_and_revoked_binding_are_refused() { + let store = bootstrap(); + let request = request(); + let mut unknown = seed(&request); + unknown.workspace_id = "unknown"; + assert!(seed_fixed_worker(&store, unknown).is_err()); + let mut unknown = seed(&request); + unknown.runtime_id = "unknown"; + assert!(seed_fixed_worker(&store, unknown).is_err()); + store + .revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2026-10-24T01:00:00Z") + .unwrap(); + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + assert!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + } + + #[test] + fn unsupported_intent_is_refused_without_partial_registration() { + let store = bootstrap(); + let base = request(); + let mut requests = Vec::new(); + let mut request = base.clone(); + request.singleton_key = Some("fixture-singleton".into()); + requests.push(request); + let mut request = base.clone(); + request.intent = WorkerSpawnIntent::WorkspaceOrchestrator; + requests.push(request); + let mut request = base.clone(); + request.resolved_subjektiv_attached = true; + requests.push(request); + let mut request = base.clone(); + request + .workdir_attachment_requests + .push(WorkerSpawnWorkingDirectoryAttachmentRequest { + alias: workdir::WorkdirAttachmentAlias::new("repo").unwrap(), + working_directory: WorkerSpawnWorkingDirectoryRequest { + repository_key: "repo".into(), + selector: None, + }, + }); + requests.push(request); + for request in requests { + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + } + assert!( + store + .get_worker_registry(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + assert!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + assert_eq!( + seed_fixed_worker(&store, seed(&base)) + .unwrap() + .worker_id + .to_string(), + WORKER + ); + } + + #[test] + fn late_catalog_failure_rolls_back_reservation_and_resource_allocation() { + let store = bootstrap(); + let request = request(); + store + .with_conn(|conn| { + conn.execute_batch( + "CREATE TEMP TRIGGER fail_fixture_catalog BEFORE INSERT ON worker_registry + BEGIN SELECT RAISE(ABORT, 'injected catalog failure'); END", + )?; + Ok(()) + }) + .unwrap(); + assert!(seed_fixed_worker(&store, seed(&request)).is_err()); + assert!( + !store + .has_active_worker_create_reservation(WORKSPACE, &worker()) + .unwrap() + ); + assert!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .is_none() + ); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER fail_fixture_catalog")?; + Ok(()) + }) + .unwrap(); + seed_fixed_worker(&store, seed(&request)).unwrap(); + assert_eq!( + store + .worker_resource_key(WORKSPACE, &worker()) + .unwrap() + .as_deref(), + Some("W-1") + ); + } + + #[test] + fn ordinary_creation_still_allocates_and_replays_without_catalog_insertion() { + let store = bootstrap(); + let memory = store.get_workspace_memory_settings(WORKSPACE).unwrap(); + let reserve = |key| { + store + .reserve_worker_create(WORKSPACE, RUNTIME, key, "fingerprint", None, &memory) + .unwrap() + }; + let first = reserve("ordinary-one"); + assert_eq!(first, reserve("ordinary-one")); + let second = reserve("ordinary-two"); + assert_ne!(first.worker_id, second.worker_id); + assert_ne!(first.worker_id.to_string(), WORKER); + assert_eq!( + first.worker_id.as_uuid().get_version(), + Some(uuid::Version::SortRand) + ); + let worker = RuntimeWorkerRef::new(RUNTIME, first.worker_id.to_string()); + assert!( + store + .has_active_worker_create_reservation(WORKSPACE, &worker) + .unwrap() + ); + assert!( + store + .get_worker_registry(WORKSPACE, &worker) + .unwrap() + .is_none() + ); + assert!( + store + .reserve_worker_create(WORKSPACE, RUNTIME, "ordinary-one", "changed", None, &memory) + .is_err() + ); + } +} diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index b308eeb49..53b60d5e3 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -9,6 +9,7 @@ autotests = false [features] default = [] e2e = [] +fixture-support = ["yoi-workspace-server/test-support", "worker-runtime/test-support"] [dependencies] libc.workspace = true @@ -17,11 +18,17 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +protocol.workspace = true +tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } [dev-dependencies] chrono = { version = "0.4", default-features = false, features = ["clock"] } yoi-workspace-server.workspace = true server-api.workspace = true +worker-runtime.workspace = true +session-store.workspace = true +config-source.workspace = true +manifest.workspace = true webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } @@ -44,3 +51,8 @@ required-features = ["e2e"] name = "worker_environment" path = "tests/worker_environment.rs" required-features = ["e2e"] + +[[test]] +name = "worker_restore" +path = "tests/worker_restore.rs" +required-features = ["e2e", "fixture-support"] diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 678300006..5766c9471 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -7,3 +7,41 @@ - [`PanelHarness`]: PTY経由の入力、観測イベントによる同期、失敗時のartifact保存。 - [`FixtureWorkspace`]: TUI用WorkspaceとHOME/XDG領域の隔離。 - [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。 +- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。 +- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。 + +## 固定Workerの段階起動 + +1. 提供fixtureを所有crateの型で検証し、`FixtureSource`で原本hashを記録する。各環境の一時領域へcopyする。fixtureが未提供・不適合なら失敗とし、代替Workerを生成しない。 +2. `initialize_runtime_identity`で固定Runtime IDに新しい鍵を発行する。Backendの型付きstore APIで新しいAccount認証・Workspace署名identity・Runtime binding・固定Worker登録を準備する。 +3. `start_seeded_backend`へ固定Workspace/Runtime IDとその環境の新しいbearerを渡す。Server起動後の公開APIからWorkspaceの公開identityを取得する。 +4. `start_runtime_with_seed`はtrust設定後、Runtime processの起動前にseed closureを呼ぶ。ここで実Server URLを使いRuntime所有crateの型付きinstallを呼ぶ。Runtime store rootは`runtime_data_dir.join("runtime")`。 +5. Worker一覧とdetailから停止状態・観測tokenを確認し、一つの固定request IDで`restore_worker`を一度だけ呼ぶ。結果不明・拒否は成功扱いせず、tokenを自動更新しない。 +6. `WorkerSessionSubscription::connect`で期待`protocol::SessionSnapshot`全体(entry ID、timestamp、順序、本文を含む)を照合する。二環境を同時に保持し、一方のstop/cleanup後も他方の購読・一覧・内容を再確認する。 +7. `stop_worker`、購読の`expect_stopped`、環境cleanup、原本`assert_unchanged`を確認する。 + +所有crateの`test-support`依存はテスト側の非default `fixture-support` featureだけで有効にする。製品binary providerは別Cargo invocationと専用`target/e2e-products`へ`yoi`・`yoi-server`・`yoi-runtime`を追加featureなしでビルドする。support test自身をこのtargetへビルドしてはならない。`YOI_E2E_BIN`等の明示binary overrideを使用する場合も、呼出し側が同じ分離を保証する。既存TUI用`e2e-test` featureは別の`target/e2e-tui-products`へビルドし、restore製品binaryへ混入しない。 + +固定資産の正常restore・並行隔離E2E: + +```sh +cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore +# 親から提供された候補の検証(読取専用、原本の自動採用や生成はしない) +YOI_E2E_RESTORE_FIXTURE=target/fixture-candidate-v1 \ + cargo test -p yoi-e2e --features e2e,fixture-support --test worker_restore +``` + +標準資産は`tests/e2e/fixtures/worker-codex-v1`の`manifest.json`・`worker-template.json`・`expected.json`・`workspace-config.dcdl`。manifestの各asset hash/size、schemaと固定ID、生成元commit、modelを検証する。Workspace configのimport参照を黙って最新へ置換せず、Gitで生成元commitに対する`resources`と`crates/builtin-source`の一致を確認する(Git履歴がない場合も失敗)。移植したSessionの正規公開projectionを供給された会話行と照合し、その全snapshotを実executionのWebSocketと比較する。欠落fixture・hash不一致・未実行は成功扱いしない。 + +通常のharness回帰試験(製品processは起動しない): + +```sh +cargo test -p yoi-e2e --lib --no-default-features +``` + +既存の製品process E2Eは別結果として実行・報告する: + +```sh +cargo test -p yoi-e2e --features e2e --test worker_environment +cargo test -p yoi-e2e --features e2e --test panel --test rewind +``` diff --git a/tests/e2e/fixtures/worker-codex-v1/expected.json b/tests/e2e/fixtures/worker-codex-v1/expected.json new file mode 100644 index 000000000..485398932 --- /dev/null +++ b/tests/e2e/fixtures/worker-codex-v1/expected.json @@ -0,0 +1,52 @@ +{ + "source_revision": "1639be98411921308238b3a6a6e75c784c045156", + "model": "codex-oauth/gpt-6.1-sol", + "worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce", + "session_id": "01a1657d-b818-77c1-bd73-175a27562f63", + "rows": [ + { + "entry_id": "01a1657f-18bf-7a03-b8b8-953d27041fe0", + "timestamp": 1792704190655, + "role": "user", + "parts": [ + { + "kind": "text", + "content": "これは保存と復元を確認するための人工的な会話です。合言葉は「青い灯台」です。覚えたことを一文で返してください。Toolは使わないでください。" + } + ] + }, + { + "entry_id": "01a1657f-1f55-71d3-b418-8199b863f59a", + "timestamp": 1792704192341, + "role": "assistant", + "parts": [ + { + "kind": "text", + "text": "合言葉は「青い灯台」と覚えました。" + } + ] + }, + { + "entry_id": "01a1657f-3053-71f3-bf4f-91290d82488b", + "timestamp": 1792704196691, + "role": "user", + "parts": [ + { + "kind": "text", + "content": "先ほどの合言葉は何でしたか?合言葉だけを答えてください。" + } + ] + }, + { + "entry_id": "01a1657f-36ef-78b1-b0c8-b141f13b0c7b", + "timestamp": 1792704198383, + "role": "assistant", + "parts": [ + { + "kind": "text", + "text": "青い灯台" + } + ] + } + ] +} diff --git a/tests/e2e/fixtures/worker-codex-v1/manifest.json b/tests/e2e/fixtures/worker-codex-v1/manifest.json new file mode 100644 index 000000000..6c26e18ef --- /dev/null +++ b/tests/e2e/fixtures/worker-codex-v1/manifest.json @@ -0,0 +1,34 @@ +{ + "format_version": 1, + "source_revision": "1639be98411921308238b3a6a6e75c784c045156", + "runtime_schema_version": 12, + "session_schema_version": 6, + "workspace_id": "01a16553-b901-7191-8ceb-8e2306fe3da9", + "runtime_id": "fixture-951707ee-93ca-4205-9c12-45f08a104a74", + "worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce", + "session_id": "01a1657d-b818-77c1-bd73-175a27562f63", + "segment_id": "01a1657d-b818-77c1-bd73-176172a4bd82", + "model": "codex-oauth/gpt-6.1-sol", + "state": "stopped", + "acquisition": "Real isolated Codex OAuth two-turn artificial conversation; typed offline StoppedWorkerTemplate extraction. Session bytes unchanged; environment credentials and execution receipts excluded.", + "source_file_sha256": { + "worker.json": "912e620013a17f05c1aaf900da48c9ee0ecb1f54121efc3721e03f8755eb5eca", + "metadata.json": "657928a89ec621bd092b86924201ed7b562e9683261789ce18bdb1d248bdc450", + "session/session.json": "22b01daab68a28b9777a1f7abb9ef9f7f6cbae113089446602f5d15421e7c7f3", + "session/segments/01a1657d-b818-77c1-bd73-176172a4bd82.jsonl": "7c5663bb06d9406d5bd0a5086159b31c0d7799458fcb1c6f81e18b63c49f4df5" + }, + "files": { + "worker-template.json": { + "bytes": 48343, + "sha256": "8bf6ce3c71c4f05c82314de93c1e8e51b7502b00915b6adb8b33046944e463b2" + }, + "expected.json": { + "bytes": 1474, + "sha256": "b9846db47ecb8f3f53b2abd9d5f0c766122f86015a7a7df7addf8beab3e9c55a" + }, + "workspace-config.dcdl": { + "bytes": 1141, + "sha256": "9b07507b71733081f077e2e9968a7f1f676c916a243bb355d1813f067a55c415" + } + } +} diff --git a/tests/e2e/fixtures/worker-codex-v1/worker-template.json b/tests/e2e/fixtures/worker-codex-v1/worker-template.json new file mode 100644 index 000000000..dfd032e64 --- /dev/null +++ b/tests/e2e/fixtures/worker-codex-v1/worker-template.json @@ -0,0 +1,5605 @@ +{ + "template_version": 1, + "runtime_schema_version": 12, + "runtime_id": "fixture-951707ee-93ca-4205-9c12-45f08a104a74", + "workspace_id": "01a16553-b901-7191-8ceb-8e2306fe3da9", + "worker_id": "01a1657d-b762-7791-87ae-a49f28cadbce", + "created_at_ms": 1792704100293, + "session_id": "01a1657d-b818-77c1-bd73-175a27562f63", + "segment_id": "01a1657d-b818-77c1-bd73-176172a4bd82", + "session_manifest": [ + 123, + 10, + 32, + 32, + 34, + 115, + 99, + 104, + 101, + 109, + 97, + 95, + 118, + 101, + 114, + 115, + 105, + 111, + 110, + 34, + 58, + 32, + 54, + 44, + 10, + 32, + 32, + 34, + 115, + 101, + 115, + 115, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 32, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 10, + 125, + 10 + ], + "segment_jsonl": [ + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 115, + 101, + 103, + 109, + 101, + 110, + 116, + 95, + 115, + 116, + 97, + 114, + 116, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 48, + 48, + 52, + 48, + 55, + 44, + 34, + 115, + 101, + 115, + 115, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 44, + 34, + 115, + 121, + 115, + 116, + 101, + 109, + 95, + 112, + 114, + 111, + 109, + 112, + 116, + 34, + 58, + 34, + 195, + 163, + 194, + 129, + 194, + 147, + 195, + 163, + 194, + 130, + 194, + 140, + 195, + 163, + 194, + 129, + 194, + 175, + 195, + 169, + 194, + 154, + 194, + 148, + 195, + 169, + 194, + 155, + 194, + 162, + 195, + 167, + 194, + 146, + 194, + 176, + 195, + 165, + 194, + 162, + 194, + 131, + 195, + 163, + 194, + 129, + 194, + 174, + 195, + 164, + 194, + 186, + 194, + 186, + 195, + 165, + 194, + 183, + 194, + 165, + 195, + 167, + 194, + 154, + 194, + 132, + 195, + 163, + 194, + 129, + 194, + 170, + 195, + 164, + 194, + 188, + 194, + 154, + 195, + 168, + 194, + 169, + 194, + 177, + 195, + 163, + 194, + 131, + 194, + 134, + 195, + 163, + 194, + 130, + 194, + 185, + 195, + 163, + 194, + 131, + 194, + 136, + 195, + 163, + 194, + 129, + 194, + 167, + 195, + 163, + 194, + 129, + 194, + 153, + 195, + 163, + 194, + 128, + 194, + 130, + 195, + 166, + 194, + 151, + 194, + 165, + 195, + 166, + 194, + 156, + 194, + 172, + 195, + 168, + 194, + 170, + 194, + 158, + 195, + 163, + 194, + 129, + 194, + 167, + 195, + 167, + 194, + 176, + 194, + 161, + 195, + 166, + 194, + 189, + 194, + 148, + 195, + 163, + 194, + 129, + 194, + 171, + 195, + 165, + 194, + 191, + 194, + 156, + 195, + 167, + 194, + 173, + 194, + 148, + 195, + 163, + 194, + 129, + 194, + 151, + 195, + 163, + 194, + 129, + 194, + 166, + 195, + 163, + 194, + 129, + 194, + 143, + 195, + 163, + 194, + 129, + 194, + 160, + 195, + 163, + 194, + 129, + 194, + 149, + 195, + 163, + 194, + 129, + 194, + 132, + 195, + 163, + 194, + 128, + 194, + 130, + 84, + 111, + 111, + 108, + 195, + 163, + 194, + 130, + 194, + 132, + 195, + 165, + 194, + 164, + 194, + 150, + 195, + 169, + 194, + 131, + 194, + 168, + 195, + 166, + 194, + 147, + 194, + 141, + 195, + 164, + 194, + 189, + 194, + 156, + 195, + 163, + 194, + 129, + 194, + 175, + 195, + 168, + 194, + 161, + 194, + 140, + 195, + 163, + 194, + 130, + 194, + 143, + 195, + 163, + 194, + 129, + 194, + 154, + 195, + 163, + 194, + 128, + 194, + 129, + 195, + 163, + 194, + 131, + 194, + 166, + 195, + 163, + 194, + 131, + 194, + 188, + 195, + 163, + 194, + 130, + 194, + 182, + 195, + 163, + 194, + 131, + 194, + 188, + 195, + 163, + 194, + 129, + 194, + 140, + 195, + 164, + 194, + 188, + 194, + 154, + 195, + 168, + 194, + 169, + 194, + 177, + 195, + 164, + 194, + 184, + 194, + 173, + 195, + 163, + 194, + 129, + 194, + 171, + 195, + 166, + 194, + 143, + 194, + 144, + 195, + 167, + 194, + 164, + 194, + 186, + 195, + 163, + 194, + 129, + 194, + 151, + 195, + 163, + 194, + 129, + 194, + 159, + 195, + 166, + 194, + 131, + 194, + 133, + 195, + 165, + 194, + 160, + 194, + 177, + 195, + 163, + 194, + 129, + 194, + 160, + 195, + 163, + 194, + 129, + 194, + 145, + 195, + 163, + 194, + 130, + 194, + 146, + 195, + 164, + 194, + 189, + 194, + 191, + 195, + 163, + 194, + 129, + 194, + 163, + 195, + 163, + 194, + 129, + 194, + 166, + 195, + 163, + 194, + 129, + 194, + 143, + 195, + 163, + 194, + 129, + 194, + 160, + 195, + 163, + 194, + 129, + 194, + 149, + 195, + 163, + 194, + 129, + 194, + 132, + 195, + 163, + 194, + 128, + 194, + 130, + 92, + 110, + 92, + 110, + 92, + 110, + 45, + 45, + 45, + 92, + 110, + 35, + 35, + 32, + 87, + 111, + 114, + 107, + 105, + 110, + 103, + 32, + 98, + 111, + 117, + 110, + 100, + 97, + 114, + 105, + 101, + 115, + 34, + 44, + 34, + 99, + 111, + 110, + 102, + 105, + 103, + 34, + 58, + 123, + 34, + 109, + 97, + 120, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 50, + 48, + 52, + 56, + 44, + 34, + 116, + 101, + 109, + 112, + 101, + 114, + 97, + 116, + 117, + 114, + 101, + 34, + 58, + 110, + 117, + 108, + 108, + 44, + 34, + 116, + 111, + 112, + 95, + 112, + 34, + 58, + 110, + 117, + 108, + 108, + 44, + 34, + 116, + 111, + 112, + 95, + 107, + 34, + 58, + 110, + 117, + 108, + 108, + 44, + 34, + 115, + 116, + 111, + 112, + 95, + 115, + 101, + 113, + 117, + 101, + 110, + 99, + 101, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 114, + 101, + 97, + 115, + 111, + 110, + 105, + 110, + 103, + 34, + 58, + 34, + 108, + 111, + 119, + 34, + 125, + 44, + 34, + 104, + 105, + 115, + 116, + 111, + 114, + 121, + 34, + 58, + 91, + 93, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 105, + 110, + 118, + 111, + 107, + 101, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 48, + 54, + 53, + 53, + 44, + 34, + 116, + 114, + 105, + 103, + 103, + 101, + 114, + 34, + 58, + 34, + 117, + 115, + 101, + 114, + 95, + 115, + 101, + 110, + 100, + 34, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 117, + 115, + 101, + 114, + 95, + 105, + 110, + 112, + 117, + 116, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 48, + 54, + 53, + 53, + 44, + 34, + 115, + 101, + 103, + 109, + 101, + 110, + 116, + 115, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 34, + 227, + 129, + 147, + 227, + 130, + 140, + 227, + 129, + 175, + 228, + 191, + 157, + 229, + 173, + 152, + 227, + 129, + 168, + 229, + 190, + 169, + 229, + 133, + 131, + 227, + 130, + 146, + 231, + 162, + 186, + 232, + 170, + 141, + 227, + 129, + 153, + 227, + 130, + 139, + 227, + 129, + 159, + 227, + 130, + 129, + 227, + 129, + 174, + 228, + 186, + 186, + 229, + 183, + 165, + 231, + 154, + 132, + 227, + 129, + 170, + 228, + 188, + 154, + 232, + 169, + 177, + 227, + 129, + 167, + 227, + 129, + 153, + 227, + 128, + 130, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 175, + 227, + 128, + 140, + 233, + 157, + 146, + 227, + 129, + 132, + 231, + 129, + 175, + 229, + 143, + 176, + 227, + 128, + 141, + 227, + 129, + 167, + 227, + 129, + 153, + 227, + 128, + 130, + 232, + 166, + 154, + 227, + 129, + 136, + 227, + 129, + 159, + 227, + 129, + 147, + 227, + 129, + 168, + 227, + 130, + 146, + 228, + 184, + 128, + 230, + 150, + 135, + 227, + 129, + 167, + 232, + 191, + 148, + 227, + 129, + 151, + 227, + 129, + 166, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 84, + 111, + 111, + 108, + 227, + 129, + 175, + 228, + 189, + 191, + 227, + 130, + 143, + 227, + 129, + 170, + 227, + 129, + 132, + 227, + 129, + 167, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 34, + 125, + 93, + 44, + 34, + 101, + 120, + 116, + 101, + 110, + 115, + 105, + 111, + 110, + 115, + 34, + 58, + 91, + 123, + 34, + 100, + 111, + 109, + 97, + 105, + 110, + 34, + 58, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 46, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 95, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 111, + 110, + 115, + 46, + 118, + 49, + 34, + 44, + 34, + 112, + 97, + 121, + 108, + 111, + 97, + 100, + 34, + 58, + 123, + 34, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 110, + 103, + 34, + 58, + 110, + 117, + 108, + 108, + 44, + 34, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 110, + 103, + 95, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 91, + 93, + 44, + 34, + 105, + 110, + 118, + 111, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 105, + 110, + 118, + 111, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 111, + 118, + 101, + 114, + 121, + 95, + 98, + 108, + 111, + 99, + 107, + 101, + 100, + 34, + 58, + 102, + 97, + 108, + 115, + 101, + 44, + 34, + 110, + 101, + 120, + 116, + 95, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 111, + 110, + 95, + 115, + 101, + 113, + 117, + 101, + 110, + 99, + 101, + 34, + 58, + 49, + 44, + 34, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 34, + 58, + 91, + 93, + 44, + 34, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 95, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 123, + 34, + 100, + 105, + 115, + 112, + 111, + 115, + 105, + 116, + 105, + 111, + 110, + 34, + 58, + 34, + 115, + 116, + 97, + 114, + 116, + 101, + 100, + 34, + 44, + 34, + 112, + 97, + 121, + 108, + 111, + 97, + 100, + 95, + 100, + 105, + 103, + 101, + 115, + 116, + 34, + 58, + 34, + 100, + 53, + 51, + 102, + 53, + 53, + 52, + 52, + 52, + 56, + 98, + 99, + 50, + 55, + 55, + 53, + 50, + 97, + 99, + 49, + 49, + 98, + 56, + 54, + 57, + 50, + 48, + 51, + 53, + 48, + 57, + 50, + 49, + 98, + 51, + 54, + 54, + 54, + 97, + 53, + 101, + 54, + 100, + 54, + 53, + 49, + 51, + 97, + 49, + 48, + 98, + 56, + 53, + 102, + 49, + 102, + 56, + 102, + 50, + 52, + 48, + 100, + 97, + 50, + 34, + 44, + 34, + 115, + 111, + 117, + 114, + 99, + 101, + 95, + 110, + 97, + 109, + 101, + 115, + 112, + 97, + 99, + 101, + 34, + 58, + 34, + 98, + 97, + 99, + 107, + 101, + 110, + 100, + 58, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 101, + 45, + 55, + 98, + 98, + 48, + 45, + 56, + 50, + 53, + 101, + 45, + 56, + 50, + 54, + 102, + 97, + 102, + 54, + 51, + 48, + 98, + 51, + 53, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 102, + 45, + 55, + 97, + 48, + 51, + 45, + 98, + 56, + 98, + 56, + 45, + 57, + 53, + 50, + 98, + 98, + 98, + 98, + 54, + 100, + 50, + 50, + 57, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 114, + 101, + 113, + 117, + 101, + 115, + 116, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 101, + 45, + 55, + 98, + 98, + 48, + 45, + 56, + 50, + 53, + 101, + 45, + 56, + 50, + 54, + 102, + 97, + 102, + 54, + 51, + 48, + 98, + 51, + 53, + 34, + 125, + 93, + 125, + 125, + 93, + 44, + 34, + 104, + 105, + 115, + 116, + 111, + 114, + 121, + 34, + 58, + 91, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 101, + 115, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 114, + 111, + 108, + 101, + 34, + 58, + 34, + 117, + 115, + 101, + 114, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 116, + 101, + 120, + 116, + 34, + 58, + 34, + 227, + 129, + 147, + 227, + 130, + 140, + 227, + 129, + 175, + 228, + 191, + 157, + 229, + 173, + 152, + 227, + 129, + 168, + 229, + 190, + 169, + 229, + 133, + 131, + 227, + 130, + 146, + 231, + 162, + 186, + 232, + 170, + 141, + 227, + 129, + 153, + 227, + 130, + 139, + 227, + 129, + 159, + 227, + 130, + 129, + 227, + 129, + 174, + 228, + 186, + 186, + 229, + 183, + 165, + 231, + 154, + 132, + 227, + 129, + 170, + 228, + 188, + 154, + 232, + 169, + 177, + 227, + 129, + 167, + 227, + 129, + 153, + 227, + 128, + 130, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 175, + 227, + 128, + 140, + 233, + 157, + 146, + 227, + 129, + 132, + 231, + 129, + 175, + 229, + 143, + 176, + 227, + 128, + 141, + 227, + 129, + 167, + 227, + 129, + 153, + 227, + 128, + 130, + 232, + 166, + 154, + 227, + 129, + 136, + 227, + 129, + 159, + 227, + 129, + 147, + 227, + 129, + 168, + 227, + 130, + 146, + 228, + 184, + 128, + 230, + 150, + 135, + 227, + 129, + 167, + 232, + 191, + 148, + 227, + 129, + 151, + 227, + 129, + 166, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 84, + 111, + 111, + 108, + 227, + 129, + 175, + 228, + 189, + 191, + 227, + 130, + 143, + 227, + 129, + 170, + 227, + 129, + 132, + 227, + 129, + 167, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 34, + 125, + 93, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 102, + 45, + 55, + 97, + 48, + 51, + 45, + 98, + 56, + 98, + 56, + 45, + 57, + 53, + 51, + 100, + 50, + 55, + 48, + 52, + 49, + 102, + 101, + 48, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 98, + 97, + 99, + 107, + 101, + 110, + 100, + 95, + 105, + 110, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 34, + 44, + 34, + 111, + 112, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 101, + 45, + 55, + 98, + 98, + 48, + 45, + 56, + 50, + 53, + 101, + 45, + 56, + 50, + 54, + 102, + 97, + 102, + 54, + 51, + 48, + 98, + 51, + 53, + 34, + 125, + 125, + 125, + 93, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 105, + 116, + 101, + 109, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 48, + 54, + 53, + 54, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 95, + 98, + 111, + 117, + 110, + 100, + 97, + 114, + 121, + 34, + 44, + 34, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 95, + 105, + 100, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 45, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 45, + 48, + 45, + 48, + 34, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 99, + 48, + 45, + 55, + 56, + 56, + 49, + 45, + 97, + 98, + 101, + 101, + 45, + 56, + 97, + 54, + 51, + 102, + 100, + 49, + 56, + 50, + 57, + 55, + 49, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 111, + 100, + 101, + 108, + 95, + 111, + 117, + 116, + 112, + 117, + 116, + 34, + 44, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 34, + 58, + 123, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 125, + 125, + 125, + 125, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 105, + 116, + 101, + 109, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 50, + 51, + 52, + 49, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 101, + 115, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 114, + 111, + 108, + 101, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 116, + 101, + 120, + 116, + 34, + 58, + 34, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 175, + 227, + 128, + 140, + 233, + 157, + 146, + 227, + 129, + 132, + 231, + 129, + 175, + 229, + 143, + 176, + 227, + 128, + 141, + 227, + 129, + 168, + 232, + 166, + 154, + 227, + 129, + 136, + 227, + 129, + 190, + 227, + 129, + 151, + 227, + 129, + 159, + 227, + 128, + 130, + 34, + 125, + 93, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 102, + 53, + 53, + 45, + 55, + 49, + 100, + 51, + 45, + 98, + 52, + 49, + 56, + 45, + 56, + 49, + 57, + 57, + 98, + 56, + 54, + 51, + 102, + 53, + 57, + 97, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 111, + 100, + 101, + 108, + 95, + 111, + 117, + 116, + 112, + 117, + 116, + 34, + 44, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 34, + 58, + 123, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 125, + 125, + 125, + 125, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 108, + 108, + 109, + 95, + 117, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 50, + 51, + 52, + 49, + 44, + 34, + 104, + 105, + 115, + 116, + 111, + 114, + 121, + 95, + 108, + 101, + 110, + 34, + 58, + 49, + 44, + 34, + 105, + 110, + 112, + 117, + 116, + 95, + 116, + 111, + 116, + 97, + 108, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 49, + 54, + 53, + 50, + 44, + 34, + 99, + 97, + 99, + 104, + 101, + 95, + 114, + 101, + 97, + 100, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 48, + 44, + 34, + 99, + 97, + 99, + 104, + 101, + 95, + 119, + 114, + 105, + 116, + 101, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 48, + 44, + 34, + 111, + 117, + 116, + 112, + 117, + 116, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 49, + 56, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 117, + 114, + 110, + 95, + 101, + 110, + 100, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 50, + 51, + 52, + 50, + 44, + 34, + 116, + 117, + 114, + 110, + 95, + 99, + 111, + 117, + 110, + 116, + 34, + 58, + 49, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 114, + 117, + 110, + 95, + 99, + 111, + 109, + 112, + 108, + 101, + 116, + 101, + 100, + 34, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 102, + 53, + 54, + 45, + 55, + 54, + 101, + 51, + 45, + 57, + 53, + 101, + 97, + 45, + 48, + 100, + 97, + 100, + 101, + 49, + 98, + 48, + 52, + 101, + 53, + 102, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 50, + 51, + 52, + 50, + 44, + 34, + 105, + 110, + 116, + 101, + 114, + 114, + 117, + 112, + 116, + 101, + 100, + 34, + 58, + 102, + 97, + 108, + 115, + 101, + 44, + 34, + 114, + 101, + 115, + 117, + 108, + 116, + 34, + 58, + 34, + 102, + 105, + 110, + 105, + 115, + 104, + 101, + 100, + 34, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 105, + 110, + 118, + 111, + 107, + 101, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 54, + 54, + 57, + 49, + 44, + 34, + 116, + 114, + 105, + 103, + 103, + 101, + 114, + 34, + 58, + 34, + 117, + 115, + 101, + 114, + 95, + 115, + 101, + 110, + 100, + 34, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 117, + 115, + 101, + 114, + 95, + 105, + 110, + 112, + 117, + 116, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 54, + 54, + 57, + 49, + 44, + 34, + 115, + 101, + 103, + 109, + 101, + 110, + 116, + 115, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 34, + 229, + 133, + 136, + 227, + 129, + 187, + 227, + 129, + 169, + 227, + 129, + 174, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 175, + 228, + 189, + 149, + 227, + 129, + 167, + 227, + 129, + 151, + 227, + 129, + 159, + 227, + 129, + 139, + 239, + 188, + 159, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 160, + 227, + 129, + 145, + 227, + 130, + 146, + 231, + 173, + 148, + 227, + 129, + 136, + 227, + 129, + 166, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 34, + 125, + 93, + 44, + 34, + 101, + 120, + 116, + 101, + 110, + 115, + 105, + 111, + 110, + 115, + 34, + 58, + 91, + 123, + 34, + 100, + 111, + 109, + 97, + 105, + 110, + 34, + 58, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 46, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 95, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 111, + 110, + 115, + 46, + 118, + 49, + 34, + 44, + 34, + 112, + 97, + 121, + 108, + 111, + 97, + 100, + 34, + 58, + 123, + 34, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 110, + 103, + 34, + 58, + 110, + 117, + 108, + 108, + 44, + 34, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 110, + 103, + 95, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 91, + 93, + 44, + 34, + 105, + 110, + 118, + 111, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 105, + 110, + 118, + 111, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 111, + 118, + 101, + 114, + 121, + 95, + 98, + 108, + 111, + 99, + 107, + 101, + 100, + 34, + 58, + 102, + 97, + 108, + 115, + 101, + 44, + 34, + 110, + 101, + 120, + 116, + 95, + 97, + 99, + 116, + 105, + 118, + 97, + 116, + 105, + 111, + 110, + 95, + 115, + 101, + 113, + 117, + 101, + 110, + 99, + 101, + 34, + 58, + 50, + 44, + 34, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 95, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 34, + 58, + 91, + 93, + 44, + 34, + 112, + 101, + 110, + 100, + 105, + 110, + 103, + 95, + 110, + 111, + 116, + 105, + 102, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 115, + 34, + 58, + 91, + 93, + 44, + 34, + 114, + 101, + 99, + 101, + 105, + 112, + 116, + 115, + 34, + 58, + 91, + 123, + 34, + 100, + 105, + 115, + 112, + 111, + 115, + 105, + 116, + 105, + 111, + 110, + 34, + 58, + 34, + 115, + 116, + 97, + 114, + 116, + 101, + 100, + 34, + 44, + 34, + 112, + 97, + 121, + 108, + 111, + 97, + 100, + 95, + 100, + 105, + 103, + 101, + 115, + 116, + 34, + 58, + 34, + 100, + 53, + 51, + 102, + 53, + 53, + 52, + 52, + 52, + 56, + 98, + 99, + 50, + 55, + 55, + 53, + 50, + 97, + 99, + 49, + 49, + 98, + 56, + 54, + 57, + 50, + 48, + 51, + 53, + 48, + 57, + 50, + 49, + 98, + 51, + 54, + 54, + 54, + 97, + 53, + 101, + 54, + 100, + 54, + 53, + 49, + 51, + 97, + 49, + 48, + 98, + 56, + 53, + 102, + 49, + 102, + 56, + 102, + 50, + 52, + 48, + 100, + 97, + 50, + 34, + 44, + 34, + 115, + 111, + 117, + 114, + 99, + 101, + 95, + 110, + 97, + 109, + 101, + 115, + 112, + 97, + 99, + 101, + 34, + 58, + 34, + 98, + 97, + 99, + 107, + 101, + 110, + 100, + 58, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 101, + 45, + 55, + 98, + 98, + 48, + 45, + 56, + 50, + 53, + 101, + 45, + 56, + 50, + 54, + 102, + 97, + 102, + 54, + 51, + 48, + 98, + 51, + 53, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 102, + 45, + 55, + 97, + 48, + 51, + 45, + 98, + 56, + 98, + 56, + 45, + 57, + 53, + 50, + 98, + 98, + 98, + 98, + 54, + 100, + 50, + 50, + 57, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 114, + 101, + 113, + 117, + 101, + 115, + 116, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 49, + 56, + 98, + 101, + 45, + 55, + 98, + 98, + 48, + 45, + 56, + 50, + 53, + 101, + 45, + 56, + 50, + 54, + 102, + 97, + 102, + 54, + 51, + 48, + 98, + 51, + 53, + 34, + 125, + 44, + 123, + 34, + 100, + 105, + 115, + 112, + 111, + 115, + 105, + 116, + 105, + 111, + 110, + 34, + 58, + 34, + 115, + 116, + 97, + 114, + 116, + 101, + 100, + 34, + 44, + 34, + 112, + 97, + 121, + 108, + 111, + 97, + 100, + 95, + 100, + 105, + 103, + 101, + 115, + 116, + 34, + 58, + 34, + 98, + 100, + 101, + 101, + 49, + 53, + 56, + 98, + 99, + 57, + 50, + 53, + 102, + 101, + 98, + 56, + 49, + 100, + 54, + 56, + 52, + 48, + 102, + 100, + 49, + 54, + 100, + 50, + 100, + 101, + 56, + 98, + 50, + 49, + 97, + 51, + 48, + 50, + 52, + 98, + 49, + 53, + 102, + 57, + 50, + 99, + 102, + 53, + 48, + 54, + 50, + 53, + 97, + 97, + 56, + 50, + 101, + 97, + 48, + 52, + 49, + 97, + 101, + 98, + 34, + 44, + 34, + 115, + 111, + 117, + 114, + 99, + 101, + 95, + 110, + 97, + 109, + 101, + 115, + 112, + 97, + 99, + 101, + 34, + 58, + 34, + 98, + 97, + 99, + 107, + 101, + 110, + 100, + 58, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 50, + 45, + 55, + 54, + 100, + 49, + 45, + 97, + 52, + 99, + 49, + 45, + 49, + 51, + 56, + 100, + 101, + 55, + 56, + 101, + 52, + 51, + 56, + 100, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 51, + 45, + 55, + 49, + 102, + 51, + 45, + 98, + 102, + 52, + 102, + 45, + 57, + 49, + 49, + 55, + 48, + 55, + 57, + 102, + 49, + 100, + 102, + 97, + 34, + 44, + 34, + 115, + 117, + 98, + 109, + 105, + 115, + 115, + 105, + 111, + 110, + 95, + 114, + 101, + 113, + 117, + 101, + 115, + 116, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 50, + 45, + 55, + 54, + 100, + 49, + 45, + 97, + 52, + 99, + 49, + 45, + 49, + 51, + 56, + 100, + 101, + 55, + 56, + 101, + 52, + 51, + 56, + 100, + 34, + 125, + 93, + 125, + 125, + 93, + 44, + 34, + 104, + 105, + 115, + 116, + 111, + 114, + 121, + 34, + 58, + 91, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 101, + 115, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 114, + 111, + 108, + 101, + 34, + 58, + 34, + 117, + 115, + 101, + 114, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 116, + 101, + 120, + 116, + 34, + 58, + 34, + 229, + 133, + 136, + 227, + 129, + 187, + 227, + 129, + 169, + 227, + 129, + 174, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 175, + 228, + 189, + 149, + 227, + 129, + 167, + 227, + 129, + 151, + 227, + 129, + 159, + 227, + 129, + 139, + 239, + 188, + 159, + 229, + 144, + 136, + 232, + 168, + 128, + 232, + 145, + 137, + 227, + 129, + 160, + 227, + 129, + 145, + 227, + 130, + 146, + 231, + 173, + 148, + 227, + 129, + 136, + 227, + 129, + 166, + 227, + 129, + 143, + 227, + 129, + 160, + 227, + 129, + 149, + 227, + 129, + 132, + 227, + 128, + 130, + 34, + 125, + 93, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 51, + 45, + 55, + 49, + 102, + 51, + 45, + 98, + 102, + 52, + 102, + 45, + 57, + 49, + 50, + 57, + 48, + 100, + 56, + 50, + 52, + 56, + 56, + 98, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 98, + 97, + 99, + 107, + 101, + 110, + 100, + 95, + 105, + 110, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 34, + 44, + 34, + 111, + 112, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 50, + 45, + 55, + 54, + 100, + 49, + 45, + 97, + 52, + 99, + 49, + 45, + 49, + 51, + 56, + 100, + 101, + 55, + 56, + 101, + 52, + 51, + 56, + 100, + 34, + 125, + 125, + 125, + 93, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 105, + 116, + 101, + 109, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 54, + 54, + 57, + 50, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 95, + 98, + 111, + 117, + 110, + 100, + 97, + 114, + 121, + 34, + 44, + 34, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 95, + 105, + 100, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 45, + 114, + 101, + 115, + 112, + 111, + 110, + 115, + 101, + 45, + 49, + 45, + 49, + 34, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 48, + 53, + 52, + 45, + 55, + 48, + 54, + 49, + 45, + 97, + 49, + 102, + 54, + 45, + 52, + 100, + 55, + 101, + 101, + 100, + 51, + 98, + 52, + 102, + 53, + 53, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 111, + 100, + 101, + 108, + 95, + 111, + 117, + 116, + 112, + 117, + 116, + 34, + 44, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 34, + 58, + 123, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 125, + 125, + 125, + 125, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 97, + 110, + 110, + 111, + 116, + 97, + 116, + 101, + 100, + 95, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 95, + 105, + 116, + 101, + 109, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 56, + 51, + 56, + 51, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 109, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 101, + 115, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 114, + 111, + 108, + 101, + 34, + 58, + 34, + 97, + 115, + 115, + 105, + 115, + 116, + 97, + 110, + 116, + 34, + 44, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 34, + 58, + 91, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 101, + 120, + 116, + 34, + 44, + 34, + 116, + 101, + 120, + 116, + 34, + 58, + 34, + 233, + 157, + 146, + 227, + 129, + 132, + 231, + 129, + 175, + 229, + 143, + 176, + 34, + 125, + 93, + 125, + 44, + 34, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97, + 34, + 58, + 123, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 54, + 101, + 102, + 45, + 55, + 56, + 98, + 49, + 45, + 98, + 48, + 99, + 56, + 45, + 98, + 49, + 52, + 49, + 102, + 49, + 51, + 98, + 48, + 99, + 55, + 98, + 34, + 44, + 34, + 111, + 114, + 105, + 103, + 105, + 110, + 34, + 58, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 109, + 111, + 100, + 101, + 108, + 95, + 111, + 117, + 116, + 112, + 117, + 116, + 34, + 44, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 34, + 58, + 123, + 34, + 119, + 111, + 114, + 107, + 101, + 114, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 100, + 45, + 98, + 56, + 49, + 56, + 45, + 55, + 55, + 99, + 49, + 45, + 98, + 100, + 55, + 51, + 45, + 49, + 55, + 53, + 97, + 50, + 55, + 53, + 54, + 50, + 102, + 54, + 51, + 34, + 125, + 125, + 125, + 125, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 108, + 108, + 109, + 95, + 117, + 115, + 97, + 103, + 101, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 56, + 51, + 56, + 52, + 44, + 34, + 104, + 105, + 115, + 116, + 111, + 114, + 121, + 95, + 108, + 101, + 110, + 34, + 58, + 52, + 44, + 34, + 105, + 110, + 112, + 117, + 116, + 95, + 116, + 111, + 116, + 97, + 108, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 49, + 54, + 57, + 54, + 44, + 34, + 99, + 97, + 99, + 104, + 101, + 95, + 114, + 101, + 97, + 100, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 49, + 53, + 51, + 54, + 44, + 34, + 99, + 97, + 99, + 104, + 101, + 95, + 119, + 114, + 105, + 116, + 101, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 48, + 44, + 34, + 111, + 117, + 116, + 112, + 117, + 116, + 95, + 116, + 111, + 107, + 101, + 110, + 115, + 34, + 58, + 56, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 116, + 117, + 114, + 110, + 95, + 101, + 110, + 100, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 56, + 51, + 56, + 52, + 44, + 34, + 116, + 117, + 114, + 110, + 95, + 99, + 111, + 117, + 110, + 116, + 34, + 58, + 50, + 125, + 10, + 123, + 34, + 107, + 105, + 110, + 100, + 34, + 58, + 34, + 114, + 117, + 110, + 95, + 99, + 111, + 109, + 112, + 108, + 101, + 116, + 101, + 100, + 34, + 44, + 34, + 101, + 110, + 116, + 114, + 121, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 49, + 97, + 49, + 54, + 53, + 55, + 102, + 45, + 51, + 54, + 102, + 48, + 45, + 55, + 102, + 53, + 48, + 45, + 97, + 52, + 56, + 102, + 45, + 56, + 55, + 55, + 99, + 98, + 54, + 97, + 100, + 100, + 53, + 56, + 54, + 34, + 44, + 34, + 116, + 115, + 34, + 58, + 49, + 55, + 57, + 50, + 55, + 48, + 52, + 49, + 57, + 56, + 51, + 56, + 52, + 44, + 34, + 105, + 110, + 116, + 101, + 114, + 114, + 117, + 112, + 116, + 101, + 100, + 34, + 58, + 102, + 97, + 108, + 115, + 101, + 44, + 34, + 114, + 101, + 115, + 117, + 108, + 116, + 34, + 58, + 34, + 102, + 105, + 110, + 105, + 115, + 104, + 101, + 100, + 34, + 125, + 10 + ] +} \ No newline at end of file diff --git a/tests/e2e/fixtures/worker-codex-v1/workspace-config.dcdl b/tests/e2e/fixtures/worker-codex-v1/workspace-config.dcdl new file mode 100644 index 000000000..60afe9221 --- /dev/null +++ b/tests/e2e/fixtures/worker-codex-v1/workspace-config.dcdl @@ -0,0 +1,22 @@ +{ + profile = { + default_profile = "project:fixture-chat"; + entries = [{ + selector = "project:fixture-chat"; + label = "Isolated Codex fixture conversation"; + description = "Artificial conversation only; no Workdir, Tools, Subject or background work."; + profile = (import "$builtin/profiles/job.dcdl") // { + slug = "fixture-chat"; + worker = { mode = "tools"; }; + model = { ref = "codex-oauth/gpt-6.1-sol"; }; + scope = "workspace_read"; + delegation_scope = "workspace_read"; + permissions = { default_action = "deny"; rule = []; }; + engine = { instruction = "default"; reasoning = "low"; max_tokens = 2048; max_turns = 4; language = "Japanese"; }; + session = { record_event_trace = false; }; + feature = { workdir_catalog = { enabled = false; }; }; + }; + }]; + }; + prompts = { default_prompt = "これは隔離環境の人工的な会話テストです。日本語で簡潔に応答してください。Toolや外部操作は行わず、ユーザーが会話中に提示した情報だけを使ってください。"; }; +} as WorkspaceConfigSchema diff --git a/tests/e2e/src/fixture_source.rs b/tests/e2e/src/fixture_source.rs new file mode 100644 index 000000000..de5100275 --- /dev/null +++ b/tests/e2e/src/fixture_source.rs @@ -0,0 +1,336 @@ +//! Bounded byte-preserving transport, independent of the owning store's schema validation. +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::{HarnessError, Result}; + +#[derive(Debug, Clone, Copy)] +struct Limits { + file_bytes: u64, + total_bytes: u64, + entries: usize, + depth: usize, +} + +const LIMITS: Limits = Limits { + file_bytes: 8 * 1024 * 1024, + total_bytes: 32 * 1024 * 1024, + entries: 256, + depth: 8, +}; + +#[derive(Debug, Clone)] +pub struct FixtureSource { + root: PathBuf, + hashes: BTreeMap, + flat_files: Option>, +} + +fn reject(message: &'static str) -> HarnessError { + HarnessError::Protocol(message.into()) +} + +impl FixtureSource { + /// Capture a bounded tree without generating or repairing store data. + pub fn capture(root: &Path) -> Result { + Self::capture_with(root, None) + } + + /// Validate the complete flat file set before reading any content. The same + /// allowlist is retained for copy and later source-integrity checks. + pub fn capture_flat(root: &Path, names: &[&str]) -> Result { + let files: BTreeSet<_> = names.iter().map(PathBuf::from).collect(); + if files.len() != names.len() + || files.iter().any(|name| { + name.components().count() != 1 + || !matches!( + name.components().next(), + Some(std::path::Component::Normal(_)) + ) + }) + { + return Err(reject("invalid fixture file allowlist")); + } + Self::capture_with(root, Some(files)) + } + + fn capture_with(root: &Path, flat_files: Option>) -> Result { + let hashes = inventory(root, flat_files.as_ref(), LIMITS)?; + if hashes.is_empty() { + return Err(reject("provided fixture is empty")); + } + Ok(Self { + root: root.to_owned(), + hashes, + flat_files, + }) + } + + /// Read a known asset through a descriptor with a real byte limit, including + /// when its size grows after metadata inspection. Errors never include paths. + pub fn read_bounded(path: &Path) -> Result> { + read_regular(path, LIMITS.file_bytes) + } + + pub fn hashes(&self) -> &BTreeMap { + &self.hashes + } + + pub fn assert_unchanged(&self) -> Result<()> { + if inventory(&self.root, self.flat_files.as_ref(), LIMITS)? != self.hashes { + return Err(reject("provided fixture changed during execution")); + } + Ok(()) + } + + /// The destination must not exist. A copy is never a link to the source. + pub fn copy_to(&self, destination: &Path) -> Result<()> { + self.assert_unchanged()?; + let parent = destination + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + if parent + .canonicalize()? + .starts_with(self.root.canonicalize()?) + { + return Err(reject("fixture copy destination is inside the original")); + } + fs::create_dir(destination)?; + let mut remaining = LIMITS.total_bytes; + for (relative, expected) in &self.hashes { + let bytes = read_regular(&self.root.join(relative), LIMITS.file_bytes.min(remaining))?; + remaining -= bytes.len() as u64; + if hash(&bytes) != *expected { + return Err(reject("fixture changed while copying")); + } + let target = destination.join(relative); + fs::create_dir_all(target.parent().unwrap())?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(target)?; + file.write_all(&bytes)?; + } + if inventory(destination, self.flat_files.as_ref(), LIMITS)? != self.hashes { + return Err(reject("fixture copy failed hash verification")); + } + self.assert_unchanged() + } +} + +fn hash(bytes: &[u8]) -> String { + format!( + "sha256:{}", + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +fn read_limited(reader: impl Read, limit: u64) -> Result> { + let mut bytes = Vec::new(); + reader + .take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| reject("fixture content read failed"))?; + if bytes.len() as u64 > limit { + return Err(reject("fixture byte budget exceeded")); + } + Ok(bytes) +} + +fn read_regular(path: &Path, limit: u64) -> Result> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a replaced final symlink or block on a replaced FIFO. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options + .open(path) + .map_err(|_| reject("fixture asset open failed"))?; + let metadata = file + .metadata() + .map_err(|_| reject("fixture asset metadata failed"))?; + if !metadata.is_file() || metadata.len() > limit { + return Err(reject("fixture asset is not a bounded regular file")); + } + read_limited(file, limit) +} + +fn inventory( + root: &Path, + flat: Option<&BTreeSet>, + limits: Limits, +) -> Result> { + struct Budget { + entries: usize, + bytes: u64, + } + fn visit( + root: &Path, + path: &Path, + depth: usize, + limits: Limits, + flat: Option<&BTreeSet>, + budget: &mut Budget, + files: &mut BTreeSet, + ) -> Result<()> { + if depth > limits.depth || budget.entries == 0 { + return Err(reject("fixture tree budget exceeded")); + } + budget.entries -= 1; + let metadata = fs::symlink_metadata(path).map_err(|_| reject("fixture metadata failed"))?; + let relative = path.strip_prefix(root).unwrap(); + if depth != 0 && flat.is_some_and(|names| !names.contains(relative)) { + return Err(reject("unexpected fixture file set")); + } + if metadata.is_dir() && (depth == 0 || flat.is_none()) { + for entry in fs::read_dir(path).map_err(|_| reject("fixture directory read failed"))? { + let entry = entry.map_err(|_| reject("fixture directory entry failed"))?; + visit(root, &entry.path(), depth + 1, limits, flat, budget, files)?; + } + } else if metadata.is_file() && depth != 0 { + if metadata.len() > limits.file_bytes || metadata.len() > budget.bytes { + return Err(reject("fixture byte budget exceeded")); + } + budget.bytes -= metadata.len(); + files.insert(relative.to_owned()); + } else { + return Err(reject("fixture contains a symlink or special file")); + } + Ok(()) + } + // Preflight the entire shape and metadata budget before reading any body. + let mut files = BTreeSet::new(); + visit( + root, + root, + 0, + limits, + flat, + &mut Budget { + entries: limits.entries, + bytes: limits.total_bytes, + }, + &mut files, + )?; + if flat.is_some_and(|expected| expected != &files) { + return Err(reject("unexpected fixture file set")); + } + let mut remaining = limits.total_bytes; + let mut hashes = BTreeMap::new(); + for relative in files { + let bytes = read_regular(&root.join(&relative), limits.file_bytes.min(remaining))?; + remaining -= bytes.len() as u64; + hashes.insert(relative, hash(&bytes)); + } + Ok(hashes) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Transport-only synthetic bytes, never a substitute saved Worker fixture. + #[test] + fn copies_are_independent_and_source_mutation_is_detected() -> Result<()> { + let temp = tempfile::tempdir()?; + let source = temp.path().join("source"); + fs::create_dir(&source)?; + fs::write(source.join("bytes"), b"original")?; + let fixture = FixtureSource::capture_flat(&source, &["bytes"])?; + assert!(fixture.copy_to(&source.join("nested-copy")).is_err()); + assert!(!source.join("nested-copy").exists()); + fixture.assert_unchanged()?; + let first = temp.path().join("first"); + let second = temp.path().join("second"); + fixture.copy_to(&first)?; + fixture.copy_to(&second)?; + fs::write(first.join("bytes"), b"changed")?; + fs::remove_dir_all(first)?; + assert_eq!(fs::read(second.join("bytes"))?, b"original"); + fixture.assert_unchanged()?; + fs::write(source.join("bytes"), b"changed")?; + assert!(fixture.assert_unchanged().is_err()); + Ok(()) + } + + #[test] + fn rejects_missing_empty_existing_destination_and_extra_file() -> Result<()> { + let temp = tempfile::tempdir()?; + assert!(FixtureSource::capture(&temp.path().join("missing")).is_err()); + assert!(FixtureSource::capture(temp.path()).is_err()); + fs::write(temp.path().join("bytes"), b"original")?; + let source = FixtureSource::capture(temp.path())?; + assert!(source.copy_to(temp.path()).is_err()); + fs::write(temp.path().join("extra"), b"new")?; + assert!(source.assert_unchanged().is_err()); + Ok(()) + } + + #[test] + fn flat_allowlist_rejects_extra_entries_before_reading_content() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::write(temp.path().join("asset"), b"ok")?; + // Sparse oversized input is never read. Even an allowed file that cannot + // be decoded does not take precedence over an unexpected file set. + fs::File::create(temp.path().join("extra"))?.set_len(LIMITS.file_bytes + 1)?; + let error = FixtureSource::capture_flat(temp.path(), &["asset"]).unwrap_err(); + assert!(error.to_string().contains("unexpected fixture file set")); + fs::remove_file(temp.path().join("extra"))?; + assert!(FixtureSource::capture_flat(temp.path(), &["missing"]).is_err()); + fs::create_dir(temp.path().join("dir"))?; + assert!(FixtureSource::capture_flat(temp.path(), &["asset", "dir"]).is_err()); + Ok(()) + } + + #[test] + fn tree_count_depth_total_and_actual_read_budgets_are_enforced() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::write(temp.path().join("a"), b"1234")?; + fs::write(temp.path().join("b"), b"1234")?; + for limits in [ + Limits { + entries: 2, + ..LIMITS + }, + Limits { + total_bytes: 7, + ..LIMITS + }, + Limits { + file_bytes: 3, + ..LIMITS + }, + ] { + assert!(inventory(temp.path(), None, limits).is_err()); + } + fs::create_dir_all(temp.path().join("nested/deep"))?; + assert!(inventory(temp.path(), None, Limits { depth: 1, ..LIMITS }).is_err()); + // A reader can return more bytes than earlier metadata reported. Bound + // actual IO rather than trusting that observation or an EOF at stat size. + assert!(read_limited(std::io::Cursor::new(b"12345"), 4).is_err()); + assert_eq!(read_limited(std::io::Cursor::new(b"1234"), 4)?, b"1234"); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn rejects_symbolic_links() -> Result<()> { + let temp = tempfile::tempdir()?; + fs::write(temp.path().join("bytes"), b"original")?; + std::os::unix::fs::symlink("bytes", temp.path().join("link"))?; + assert!(FixtureSource::capture(temp.path()).is_err()); + assert!(FixtureSource::read_bounded(&temp.path().join("link")).is_err()); + Ok(()) + } +} diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 176c7c2ff..9fcdb4ff0 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -137,7 +137,9 @@ fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy { #[derive(Debug)] pub enum HarnessError { Io(io::Error), - Json(serde_json::Error), + // Never retain decode errors: serde messages can contain rejected field names + // or values from private fixture/protocol payloads, including through Debug. + Json, CommandFailed { program: PathBuf, args: Vec, @@ -163,7 +165,7 @@ impl std::fmt::Display for HarnessError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(err) => write!(f, "io error: {err}"), - Self::Json(err) => write!(f, "json error: {err}"), + Self::Json => write!(f, "invalid JSON document"), Self::CommandFailed { program, args, @@ -209,8 +211,8 @@ impl From for HarnessError { } impl From for HarnessError { - fn from(value: serde_json::Error) -> Self { - Self::Json(value) + fn from(_: serde_json::Error) -> Self { + Self::Json } } @@ -1268,7 +1270,11 @@ impl Drop for PanelHarness { } } +mod fixture_source; +pub use fixture_source::FixtureSource; mod worker_environment; +mod worker_session; +pub use worker_session::{WorkerSessionSubscription, validate_full_snapshot}; pub use worker_environment::{ WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport, @@ -1627,6 +1633,8 @@ fn resolve_yoi_binary() -> Result { "e2e-test".to_owned(), "--bin".to_owned(), "yoi".to_owned(), + "--target-dir".to_owned(), + tui_product_target_dir()?.to_string_lossy().into_owned(), ]; if test_profile() == "release" { args.push("--release".to_owned()); @@ -1648,7 +1656,9 @@ fn resolve_yoi_binary() -> Result { }); } - let binary = current_target_profile_dir()?.join(binary_name()); + let binary = tui_product_target_dir()? + .join(test_profile()) + .join(binary_name()); let info = BinaryProviderInfo { provider: "cargo-build".to_owned(), binary, @@ -1675,6 +1685,26 @@ fn workspace_root() -> Result { .ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned())) } +/// Product processes must never reuse the target used by test-support builds. +fn product_target_dir() -> Result { + dedicated_product_target("e2e-products") +} + +// Existing PTY tests need their observer feature, never on restore binaries. +fn tui_product_target_dir() -> Result { + dedicated_product_target("e2e-tui-products") +} + +fn dedicated_product_target(name: &str) -> Result { + let target = workspace_root()?.join("target").join(name); + if current_target_profile_dir()?.parent() == Some(target.as_path()) { + return Err(HarnessError::Protocol( + "run support tests outside reserved product binary targets".to_owned(), + )); + } + Ok(target) +} + fn current_target_profile_dir() -> Result { let mut path = std::env::current_exe()?; while let Some(name) = path.file_name().and_then(|name| name.to_str()) { diff --git a/tests/e2e/src/worker_environment.rs b/tests/e2e/src/worker_environment.rs index 2183560cd..1fbdc4b0b 100644 --- a/tests/e2e/src/worker_environment.rs +++ b/tests/e2e/src/worker_environment.rs @@ -13,12 +13,13 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use super::{ - FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, - current_target_profile_dir, now_ms, test_profile, workspace_root, yoi_binary_info, + FixtureAllocation, HarnessError, Result, allocate_fixture, command_display, now_ms, + product_target_dir, test_profile, workspace_root, }; const STARTUP_WAIT: Duration = Duration::from_secs(15); const EXIT_WAIT: Duration = Duration::from_secs(5); +const SETUP_WAIT: Duration = Duration::from_secs(15); const HTTP_WAIT_STEP: Duration = Duration::from_millis(50); const MAX_PROCESS_LOG_BYTES: usize = 256 * 1024; const MAX_COMMAND_OUTPUT_BYTES: usize = 64 * 1024; @@ -61,22 +62,20 @@ pub fn worker_binary_provider_info() -> Result { fn resolve_worker_binaries() -> Result { let workspace_root = workspace_root()?; let profile = test_profile(); - let yoi = yoi_binary_info()?; - let mut records = vec![binary_record( - "yoi", - yoi.binary, - yoi.provider, - yoi.build_command, - )?]; - + let mut records = Vec::new(); + let yoi_override = resolve_override("YOI_E2E_BIN", &workspace_root)?; let server_override = resolve_override("YOI_E2E_SERVER_BIN", &workspace_root)?; let runtime_override = resolve_override("YOI_E2E_RUNTIME_BIN", &workspace_root)?; let mut build_command = None; - if server_override.is_none() || runtime_override.is_none() { + if yoi_override.is_none() || server_override.is_none() || runtime_override.is_none() { let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); let mut args = vec![ "build".to_string(), "-p".to_string(), + "yoi".to_string(), + "--bin".to_string(), + "yoi".to_string(), + "-p".to_string(), "yoi-workspace-server".to_string(), "--bin".to_string(), "yoi-server".to_string(), @@ -84,6 +83,8 @@ fn resolve_worker_binaries() -> Result { "worker-runtime".to_string(), "--bin".to_string(), "yoi-runtime".to_string(), + "--target-dir".to_string(), + product_target_dir()?.to_string_lossy().into_owned(), ]; if profile == "release" { args.push("--release".to_string()); @@ -106,7 +107,16 @@ fn resolve_worker_binaries() -> Result { build_command = Some(rendered); } - let profile_dir = current_target_profile_dir()?; + let profile_dir = product_target_dir()?.join(&profile); + let (yoi_path, yoi_provider, yoi_build) = match yoi_override { + Some(path) => (path, "YOI_E2E_BIN".to_string(), None), + None => ( + profile_dir.join(executable_name("yoi")), + "cargo-build".to_string(), + build_command.clone(), + ), + }; + records.push(binary_record("yoi", yoi_path, yoi_provider, yoi_build)?); let (server_path, server_provider, server_build) = match server_override { Some(path) => (path, "YOI_E2E_SERVER_BIN".to_string(), None), None => ( @@ -196,6 +206,17 @@ fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum WorkerProcessShutdownOutcome { + #[default] + NotStarted, + Succeeded, + Failed { + error: String, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerEnvironmentCleanupReport { pub fixture_root: PathBuf, @@ -204,6 +225,10 @@ pub struct WorkerEnvironmentCleanupReport { pub cleanup_success: bool, pub fixture_root_exists_after: bool, pub cleanup_error: Option, + pub runtime_shutdown: WorkerProcessShutdownOutcome, + pub server_shutdown: WorkerProcessShutdownOutcome, + pub root_cleanup_success: bool, + pub root_cleanup_error: Option, pub report_path: PathBuf, } @@ -231,6 +256,10 @@ pub struct WorkerE2eEnvironment { pub runtime_url: Option, server: Option, runtime: Option, + runtime_shutdown: WorkerProcessShutdownOutcome, + server_shutdown: WorkerProcessShutdownOutcome, + shutdown_error: Option, + runtime_public_identity: Option, access_token: Option, client: Client, binaries: WorkerBinaryProviderInfo, @@ -292,6 +321,10 @@ impl WorkerE2eEnvironment { runtime_url: None, server: None, runtime: None, + runtime_shutdown: WorkerProcessShutdownOutcome::NotStarted, + server_shutdown: WorkerProcessShutdownOutcome::NotStarted, + shutdown_error: None, + runtime_public_identity: None, access_token: None, client, binaries, @@ -319,8 +352,9 @@ impl WorkerE2eEnvironment { "Backend-only restart requires a running Server and no Runtime".to_string(), )); } - if let Some(mut server) = self.server.take() { - server.shutdown(EXIT_WAIT)?; + shutdown_process(&mut self.server, &mut self.server_shutdown); + if let WorkerProcessShutdownOutcome::Failed { error } = &self.server_shutdown { + return Err(HarnessError::Protocol(error.clone())); } self.server_url = None; self.start_server_process() @@ -374,6 +408,47 @@ impl WorkerE2eEnvironment { Ok(()) } + /// Start a store populated by the Backend's typed test-support API. The seed + /// supplies fixed catalog IDs, but the bearer must be freshly issued for this + /// environment. No database mutation or fixture credential import occurs here. + pub fn start_seeded_backend( + &mut self, + workspace_id: String, + runtime_id: String, + access_token: String, + ) -> Result<()> { + if self.server.is_some() || self.runtime.is_some() || self.workspace_id.is_some() { + return Err(HarnessError::Protocol( + "fixture seed requires an unstarted environment".into(), + )); + } + if workspace_id.is_empty() || runtime_id.is_empty() || access_token.is_empty() { + return Err(HarnessError::Protocol( + "fixture seed omitted an identity or fresh bearer".into(), + )); + } + if !self.server_data_dir.join("server.db").is_file() { + return Err(HarnessError::Protocol( + "typed Backend seed has not created server.db".into(), + )); + } + if self.runtime_public_identity.is_some() { + if self.runtime_id != runtime_id { + return Err(HarnessError::Protocol( + "seeded Runtime ID differs from initialized identity".into(), + )); + } + } else { + assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; + } + self.workspace_id = Some(workspace_id); + self.runtime_id = runtime_id; + self.access_token = Some(access_token); + self.start_server_process()?; + self.export_workspace_public_identity()?; + self.write_metadata("seeded-backend-running") + } + pub fn start_backend(&mut self) -> Result<()> { self.start_empty_backend()?; let server_binary = self.binaries.binary("yoi-server")?; @@ -417,7 +492,15 @@ impl WorkerE2eEnvironment { "create Workspace", )?; let workspace_id = json_string(&created, "/workspace/workspace_id")?; - self.workspace_id = Some(workspace_id.clone()); + self.workspace_id = Some(workspace_id); + self.export_workspace_public_identity()?; + self.write_metadata("backend-running")?; + Ok(()) + } + + fn export_workspace_public_identity(&self) -> Result<()> { + let server_url = self.required_server_url()?; + let workspace_id = self.required_workspace_id()?; let identity = self.request_json( self.client.get(format!( "{server_url}/api/w/{workspace_id}/settings/signing-identity" @@ -429,17 +512,27 @@ impl WorkerE2eEnvironment { })?; let bundle_path = self.root.join("workspace-public-identity.json"); fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?; - self.write_metadata("backend-running")?; Ok(()) } fn start_runtime(&mut self) -> Result<()> { - let bundle_path = self.root.join("workspace-public-identity.json"); - let server_url = self.required_server_url()?.to_string(); - let workspace_id = self.required_workspace_id()?.to_string(); - let runtime_binary = self.binaries.binary("yoi-runtime")?; + self.start_runtime_with_seed(|_| Ok(()))?; + self.assert_connected_and_empty() + } + + /// Generate fresh keys before offline Backend registration when fixed IDs are + /// required. Returns only the public bundle; no fixture keys are accepted. + pub fn initialize_runtime_identity(&mut self, runtime_id: String) -> Result { + if self.runtime_public_identity.is_some() || self.runtime.is_some() { + return Err(HarnessError::Protocol( + "Runtime identity is already initialized".into(), + )); + } + assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; + self.runtime_id = runtime_id; + let binary = self.binaries.binary("yoi-runtime")?; self.run_command( - &runtime_binary, + &binary, &[ "identity", "init", @@ -450,8 +543,8 @@ impl WorkerE2eEnvironment { ], false, )?; - let runtime_identity = self.run_command( - &runtime_binary, + let identity = self.run_command( + &binary, &[ "identity", "show", @@ -461,7 +554,31 @@ impl WorkerE2eEnvironment { ], false, )?; - let runtime_identity: Value = serde_json::from_slice(&runtime_identity.stdout)?; + let identity: Value = serde_json::from_slice(&identity.stdout)?; + self.runtime_public_identity = Some(identity.clone()); + Ok(identity) + } + + /// Initialize fresh Runtime keys/trust, then invoke the typed Runtime seed + /// before starting its process. The actual Backend URL is now available. + pub fn start_runtime_with_seed( + &mut self, + seed: impl FnOnce(&Self) -> Result<()>, + ) -> Result<()> { + if self.runtime.is_some() { + return Err(HarnessError::Protocol("Runtime is already running".into())); + } + if self.runtime_public_identity.is_none() { + self.initialize_runtime_identity(self.runtime_id.clone())?; + } + let runtime_identity = self + .runtime_public_identity + .clone() + .expect("initialized identity"); + let bundle_path = self.root.join("workspace-public-identity.json"); + let server_url = self.required_server_url()?.to_string(); + let workspace_id = self.required_workspace_id()?.to_string(); + let runtime_binary = self.binaries.binary("yoi-runtime")?; self.run_command( &runtime_binary, &[ @@ -497,6 +614,8 @@ impl WorkerE2eEnvironment { )); } + seed(self)?; + self.write_metadata("runtime-seeded")?; let mut command = self.isolated_command(&runtime_binary); command.args([ "--bind", @@ -519,22 +638,38 @@ impl WorkerE2eEnvironment { self.runtime = Some(runtime); self.runtime_url = Some(runtime_url.clone()); + let registered = self.request_json( + self.client + .get(format!("{server_url}/api/w/{workspace_id}/runtimes")), + "observe Runtime registration before endpoint update", + )?; + let expected_binding_id = registered + .get("items") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str()) + }) + }) + .and_then(|item| item.pointer("/management/binding/binding_id")) + .cloned(); self.request_json( self.client .post(format!("{server_url}/api/w/{workspace_id}/runtimes")) .json(&serde_json::json!({ "public_bundle": runtime_identity, "display_name": format!("E2E {}", self.runtime_id), - "endpoint": runtime_url + "endpoint": runtime_url, + "expected_binding_id": expected_binding_id, })), "register Runtime", )?; - self.assert_connected_and_empty()?; + self.assert_connected()?; self.write_metadata("running")?; Ok(()) } - pub fn assert_connected_and_empty(&self) -> Result<()> { + pub fn assert_connected(&self) -> Result<()> { let server_url = self.required_server_url()?; let workspace_id = self.required_workspace_id()?; // connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。 @@ -545,47 +680,125 @@ impl WorkerE2eEnvironment { )), "verify Runtime connection", )?; - if connection.get("status").and_then(Value::as_str) != Some("compatible") { - return Err(HarnessError::Protocol(format!( - "Runtime connection was not compatible: {connection}" - ))); - } + require_compatible_connection(&connection)?; let binding_id = json_string(&connection, "/binding_id")?; let runtimes = self.request_json( self.client .get(format!("{server_url}/api/w/{workspace_id}/runtimes")), "list Runtimes", )?; - let connected = runtimes - .get("items") - .and_then(Value::as_array) - .is_some_and(|items| { - items.iter().any(|item| { - item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str()) - && item - .pointer("/management/binding/binding_id") - .and_then(Value::as_str) - == Some(binding_id.as_str()) - }) - }); - if !connected { - return Err(HarnessError::Protocol(format!( - "Runtime {} is not verified in {runtimes}", - self.runtime_id - ))); - } - let workers = self.request_json( + require_registered_runtime(&runtimes, &self.runtime_id, &binding_id) + } + + pub fn list_workers(&self) -> Result { + let server_url = self.required_server_url()?; + let workspace_id = self.required_workspace_id()?; + self.request_json( self.client .get(format!("{server_url}/api/w/{workspace_id}/workers")), "list Workers", - )?; - if workers - .get("items") - .and_then(Value::as_array) - .is_none_or(|items| !items.is_empty()) - { + ) + } + + pub fn assert_connected_and_empty(&self) -> Result<()> { + self.assert_connected()?; + let workers = self.list_workers()?; + require_empty_worker_list(&workers) + } + + fn worker_url(&self, worker_id: &str, suffix: &str) -> Result { + let mut url = reqwest::Url::parse(self.required_server_url()?) + .map_err(|error| HarnessError::Protocol(format!("invalid Server URL: {error}")))?; + url.path_segments_mut() + .map_err(|_| HarnessError::Protocol("Server URL has no path segments".into()))? + .extend([ + "api", + "w", + self.required_workspace_id()?, + "runtimes", + &self.runtime_id, + "workers", + worker_id, + ]); + if !suffix.is_empty() { + url.path_segments_mut().unwrap().extend(suffix.split('/')); + } + Ok(url.into()) + } + + pub fn worker_detail(&self, worker_id: &str) -> Result { + self.request_json( + self.client.get(self.worker_url(worker_id, "")?), + "read Worker detail", + ) + } + + /// Exactly one restore submission. No retries, replacement token or new intent. + pub fn restore_worker( + &self, + worker_id: &str, + request_id: &str, + expected_observation_token: &str, + ) -> Result { + self.request_json( + self.client + .post(self.worker_url(worker_id, "restore")?) + .timeout(STARTUP_WAIT) + .json(&serde_json::json!({ + "request_id": request_id, + "expected_observation_token": expected_observation_token, + })), + "restore Worker", + ) + } + + pub fn stop_worker(&self, worker_id: &str) -> Result { + self.request_json( + self.client + .post(self.worker_url(worker_id, "stop")?) + .json(&serde_json::json!({})), + "stop Worker", + ) + } + + /// Authorization stays in memory; callers must not persist or debug this request. + pub fn worker_protocol_request(&self, worker_id: &str) -> Result { + let token = self.access_token.as_deref().ok_or_else(|| { + HarnessError::Protocol("Backend access token is not initialized".into()) + })?; + self.client + .get(self.worker_url(worker_id, "protocol/ws")?) + .bearer_auth(token) + .build() + .map_err(|_| HarnessError::Protocol("build Worker protocol request".into())) + } + + /// The other environment must reject this environment's fresh bearer even + /// when both contain identical Workspace/Runtime/Worker IDs. Never logs tokens. + pub fn assert_authentication_isolated_from(&self, other: &Self) -> Result<()> { + let token = self + .access_token + .as_deref() + .ok_or_else(|| HarnessError::Protocol("missing source bearer".into()))?; + if other.access_token.as_deref() == Some(token) { + return Err(HarnessError::Protocol( + "environments share authentication material".into(), + )); + } + let (server, workspace) = other.backend_target()?; + let response = self + .client + .get(format!("{server}/api/w/{workspace}/workers")) + .bearer_auth(token) + .send() + .map_err(|_| { + HarnessError::Protocol("cross-environment authorization request failed".into()) + })?; + let status = response.status().as_u16(); + other.append_operation("reject other environment bearer", status)?; + if status != 401 { return Err(HarnessError::Protocol(format!( - "fresh Worker list is not empty: {workers}" + "cross-environment bearer was not rejected with 401: {status}" ))); } Ok(()) @@ -636,7 +849,7 @@ impl WorkerE2eEnvironment { "assignee": null, "readiness": null, "risk_flags": [], - "workflow_state": "planning", + "workflow_state": "open", "queued_by": null, "queued_at": null, "repository_key": null, @@ -697,30 +910,53 @@ impl WorkerE2eEnvironment { } pub fn shutdown(&mut self) -> Result<()> { - if let Some(mut runtime) = self.runtime.take() { - runtime.shutdown(EXIT_WAIT)?; + // 一方の異常でも他方を回収し、再呼出しやroot削除で失敗を消さない。 + shutdown_process(&mut self.runtime, &mut self.runtime_shutdown); + shutdown_process(&mut self.server, &mut self.server_shutdown); + let mut errors = Vec::new(); + for (name, outcome) in [ + ("Runtime", &self.runtime_shutdown), + ("Server", &self.server_shutdown), + ] { + if let WorkerProcessShutdownOutcome::Failed { error } = outcome { + errors.push(format!("{name}: {error}")); + } } - if let Some(mut server) = self.server.take() { - server.shutdown(EXIT_WAIT)?; + if self.write_metadata("stopped").is_err() { + errors.push("write stopped metadata failed".to_string()); + } + if self.shutdown_error.is_none() && !errors.is_empty() { + self.shutdown_error = Some(errors.join("; ")); + } + match &self.shutdown_error { + Some(error) => Err(HarnessError::Protocol(error.clone())), + None => Ok(()), } - self.write_metadata("stopped")?; - Ok(()) } pub fn cleanup(mut self) -> Result { - self.shutdown()?; self.cleanup_inner(true) } fn cleanup_inner(&mut self, strict: bool) -> Result { - let mut cleanup_error = None; + let _ = self.shutdown(); + let mut root_cleanup_error = None; if let Some(temp_root) = self.temp_root.take() - && let Err(error) = temp_root.close() + && temp_root.close().is_err() { - cleanup_error = Some(error.to_string()); + root_cleanup_error = Some("remove fixture root failed".to_string()); } let fixture_root_exists_after = self.root.exists(); - let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after; + if fixture_root_exists_after && root_cleanup_error.is_none() { + root_cleanup_error = Some("fixture root still exists".to_string()); + } + let root_cleanup_success = root_cleanup_error.is_none() && !fixture_root_exists_after; + let cleanup_error = match (&self.shutdown_error, &root_cleanup_error) { + (Some(shutdown), Some(root)) => Some(format!("{shutdown}; {root}")), + (Some(error), _) | (_, Some(error)) => Some(error.clone()), + (None, None) => None, + }; + let cleanup_success = self.shutdown_error.is_none() && root_cleanup_success; let report = WorkerEnvironmentCleanupReport { fixture_root: self.root.clone(), artifacts_dir: self.artifacts_dir.clone(), @@ -728,6 +964,10 @@ impl WorkerE2eEnvironment { cleanup_success, fixture_root_exists_after, cleanup_error, + runtime_shutdown: self.runtime_shutdown.clone(), + server_shutdown: self.server_shutdown.clone(), + root_cleanup_success, + root_cleanup_error, report_path: self.artifacts_dir.join("cleanup.json"), }; fs::write(&report.report_path, serde_json::to_vec_pretty(&report)?)?; @@ -773,18 +1013,8 @@ impl WorkerE2eEnvironment { let response = request .send() .map_err(|error| HarnessError::Protocol(format!("{operation}: {error}")))?; - let status = response.status(); - let body = response - .bytes() - .map_err(|error| HarnessError::Protocol(format!("{operation}: {error}")))?; - self.append_operation(operation, status.as_u16())?; - if !status.is_success() { - return Err(HarnessError::Protocol(format!( - "{operation} returned {status}: {}", - bounded_text(&body, MAX_COMMAND_OUTPUT_BYTES) - ))); - } - serde_json::from_slice(&body).map_err(HarnessError::from) + self.append_operation(operation, response.status().as_u16())?; + decode_api_response(response, operation) } fn append_operation(&self, operation: &str, status: u16) -> Result<()> { @@ -809,13 +1039,33 @@ impl WorkerE2eEnvironment { binary: &Path, args: &[&str], redact_stdout: bool, + ) -> Result { + self.run_command_with_timeout(binary, args, redact_stdout, SETUP_WAIT) + } + + fn run_command_with_timeout( + &self, + binary: &Path, + args: &[&str], + redact_stdout: bool, + timeout: Duration, ) -> Result { let args_owned = args .iter() .map(|arg| (*arg).to_string()) .collect::>(); let mut command = self.isolated_command(binary); - let output = command.args(args).output()?; + command.args(args).stdin(Stdio::null()); + // credentialを含むstdoutは呼出元へだけ返す。raw fileは作らない。 + let mut process = ManagedProcess::spawn_captured( + "setup command", + command, + None, + None, + MAX_COMMAND_OUTPUT_BYTES + 1, + )?; + let result = process.collect_output(timeout); + let output = result.as_ref().ok(); let mut log = OpenOptions::new() .create(true) .append(true) @@ -826,17 +1076,21 @@ impl WorkerE2eEnvironment { "ts_ms": now_ms(), "binary": binary, "args": args, - "status": output.status.code(), + "status": output.and_then(|output| output.status.code()), "stdout": if redact_stdout { "".to_string() } else { - bounded_text(&output.stdout, MAX_COMMAND_OUTPUT_BYTES) + output.map(|output| bounded_text(&output.stdout, MAX_COMMAND_OUTPUT_BYTES)) + .unwrap_or_default() }, - "stderr": bounded_text(&output.stderr, MAX_COMMAND_OUTPUT_BYTES), + "stderr": output.map(|output| bounded_text(&output.stderr, MAX_COMMAND_OUTPUT_BYTES)) + .unwrap_or_default(), + "error": result.as_ref().err().map(ToString::to_string), "env_clear": true, }), )?; writeln!(log)?; + let output = result?; if !output.status.success() { return Err(HarnessError::CommandFailed { program: binary.to_path_buf(), @@ -908,16 +1162,52 @@ impl WorkerE2eEnvironment { } } +// The operation is a caller-owned static label, never a response field. Refused +// response bodies may contain secrets; do not even collect them for diagnostics. +fn decode_api_response(response: reqwest::blocking::Response, operation: &str) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(HarnessError::Protocol(format!( + "{operation} returned HTTP {}", + status.as_u16() + ))); + } + let mut body = std::io::Read::take(response, MAX_COMMAND_OUTPUT_BYTES as u64 + 1); + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut body, &mut bytes) + .map_err(|_| HarnessError::Protocol(format!("{operation}: response read failed")))?; + if bytes.len() > MAX_COMMAND_OUTPUT_BYTES { + return Err(HarnessError::Protocol(format!( + "{operation}: response byte budget exceeded" + ))); + } + serde_json::from_slice(&bytes).map_err(HarnessError::from) +} + impl Drop for WorkerE2eEnvironment { fn drop(&mut self) { - if let Some(mut runtime) = self.runtime.take() { - let _ = runtime.shutdown(EXIT_WAIT); - } - if let Some(mut server) = self.server.take() { - let _ = server.shutdown(EXIT_WAIT); - } if self.temp_root.is_some() { let _ = self.cleanup_inner(false); + } else if self.runtime.is_some() || self.server.is_some() { + let _ = self.shutdown(); + } + } +} + +fn shutdown_process( + process: &mut Option, + outcome: &mut WorkerProcessShutdownOutcome, +) { + if let Some(mut process) = process.take() { + let next = match process.shutdown(EXIT_WAIT) { + Ok(_) => WorkerProcessShutdownOutcome::Succeeded, + // shutdownはstatus/OSエラーだけを返し、captureしたstdout/stderrは含めない。 + Err(error) => WorkerProcessShutdownOutcome::Failed { + error: error.to_string(), + }, + }; + if !matches!(outcome, WorkerProcessShutdownOutcome::Failed { .. }) { + *outcome = next; } } } @@ -928,15 +1218,26 @@ struct ManagedProcess { child: Child, stdout: Arc>>, stderr: Arc>>, - readers: Vec>, + readers: Vec>>, } impl ManagedProcess { - fn spawn( + fn spawn(name: &str, command: Command, stdout_path: &Path, stderr_path: &Path) -> Result { + Self::spawn_captured( + name, + command, + Some(stdout_path.to_path_buf()), + Some(stderr_path.to_path_buf()), + MAX_PROCESS_LOG_BYTES, + ) + } + + fn spawn_captured( name: &str, mut command: Command, - stdout_path: &Path, - stderr_path: &Path, + stdout_path: Option, + stderr_path: Option, + capture_limit: usize, ) -> Result { command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = command.spawn()?; @@ -951,8 +1252,8 @@ impl ManagedProcess { let stdout = Arc::new(Mutex::new(Vec::new())); let stderr = Arc::new(Mutex::new(Vec::new())); let readers = vec![ - capture_stream(stdout_pipe, stdout_path.to_path_buf(), Arc::clone(&stdout)), - capture_stream(stderr_pipe, stderr_path.to_path_buf(), Arc::clone(&stderr)), + capture_stream(stdout_pipe, stdout_path, Arc::clone(&stdout), capture_limit), + capture_stream(stderr_pipe, stderr_path, Arc::clone(&stderr), capture_limit), ]; Ok(Self { name: name.to_string(), @@ -1023,64 +1324,161 @@ impl ManagedProcess { ) } - fn shutdown(&mut self, timeout: Duration) -> Result { - if let Some(status) = self.child.try_wait()? { - self.join_readers(); - return Ok(status); + fn collect_output(&mut self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let result = self.wait_until(deadline); + let status = match result { + Ok(Some(status)) => status, + other => { + // timeoutでも所有childをkillしてreapする。出力は診断へコピーしない。 + let _ = self.kill_and_reap(EXIT_WAIT); + let _ = self.join_readers_until(Instant::now() + EXIT_WAIT); + return match other { + Err(error) => Err(error), + _ => Err(HarnessError::Protocol( + "setup command deadline exceeded".into(), + )), + }; + } + }; + // child終了後もpipeが保持される場合を、同じsetup deadlineで失敗にする。 + self.join_readers_until(deadline)?; + let stdout = self + .stdout + .lock() + .map_err(|_| HarnessError::Protocol("setup stdout capture poisoned".into()))? + .clone(); + let stderr = self + .stderr + .lock() + .map_err(|_| HarnessError::Protocol("setup stderr capture poisoned".into()))? + .clone(); + if stdout.len() > MAX_COMMAND_OUTPUT_BYTES || stderr.len() > MAX_COMMAND_OUTPUT_BYTES { + return Err(HarnessError::Protocol( + "setup command output byte budget exceeded".into(), + )); } - let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; - if signal_result != 0 { - return Err(io_error("send SIGTERM", std::io::Error::last_os_error())); - } - let start = Instant::now(); + Ok(std::process::Output { + status, + stdout, + stderr, + }) + } + + fn wait_until(&mut self, deadline: Instant) -> Result> { loop { if let Some(status) = self.child.try_wait()? { - self.join_readers(); - if status.success() { - return Ok(status); - } - return Err(HarnessError::Protocol(format!( - "{} did not complete graceful shutdown: {status}", - self.name - ))); + return Ok(Some(status)); } - if start.elapsed() >= timeout { - let _ = self.child.kill(); - let _ = self.child.wait(); - self.join_readers(); - return Err(HarnessError::Protocol(format!( - "{} did not exit within {timeout:?}", - self.name - ))); + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); } - thread::sleep(Duration::from_millis(20)); + thread::sleep(remaining.min(Duration::from_millis(20))); } } - fn join_readers(&mut self) { - for reader in self.readers.drain(..) { - let _ = reader.join(); + fn kill_and_reap(&mut self, timeout: Duration) -> Result { + if let Ok(Some(status)) = self.child.try_wait() { + return Ok(status); } + // killと自然終了の競合ではtry_waitで回収を確認する。 + let _ = self.child.kill(); + self.wait_until(Instant::now() + timeout)?.ok_or_else(|| { + HarnessError::Protocol(format!( + "{} could not be reaped within {timeout:?}", + self.name + )) + }) + } + + fn shutdown(&mut self, timeout: Duration) -> Result { + let result = (|| { + if let Some(status) = self.child.try_wait()? { + return Ok(status); + } + let signal_result = unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; + if signal_result != 0 { + return Err(io_error("send SIGTERM", std::io::Error::last_os_error())); + } + self.wait_until(Instant::now() + timeout)?.ok_or_else(|| { + HarnessError::Protocol(format!("{} did not exit within {timeout:?}", self.name)) + }) + })(); + if result.is_err() { + let _ = self.kill_and_reap(EXIT_WAIT); + } + let readers = self.join_readers_until(Instant::now() + EXIT_WAIT); + let status = result?; + readers?; + self.require_successful_exit(status) + } + + fn require_successful_exit(&self, status: ExitStatus) -> Result { + if status.success() { + Ok(status) + } else { + Err(HarnessError::Protocol(format!( + "{} did not complete graceful shutdown: {status}", + self.name + ))) + } + } + + fn join_readers_until(&mut self, deadline: Instant) -> Result<()> { + let mut failed = false; + while !self.readers.is_empty() { + let mut index = 0; + while index < self.readers.len() { + if self.readers[index].is_finished() { + let reader = self.readers.swap_remove(index); + if !matches!(reader.join(), Ok(Ok(()))) { + failed = true; + } + } else { + index += 1; + } + } + if self.readers.is_empty() { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + // stdのblocking Readは取消せない。異常なpipe保持時はthreadをdetachし、 + // 呼出元の待機だけをboundedにする。子孫processの回収までは保証しない。 + self.readers.clear(); + return Err(HarnessError::Protocol(format!( + "{} output readers did not finish before deadline", + self.name + ))); + } + thread::sleep(remaining.min(Duration::from_millis(20))); + } + if failed { + return Err(HarnessError::Protocol(format!( + "{} output reader failed", + self.name + ))); + } + Ok(()) } } impl Drop for ManagedProcess { fn drop(&mut self) { - if self.child.try_wait().ok().flatten().is_none() { - let _ = self.child.kill(); - let _ = self.child.wait(); - } - self.join_readers(); + let _ = self.kill_and_reap(EXIT_WAIT); + let _ = self.join_readers_until(Instant::now() + EXIT_WAIT); } } fn capture_stream( mut stream: impl Read + Send + 'static, - path: PathBuf, + path: Option, shared: Arc>>, -) -> JoinHandle<()> { + capture_limit: usize, +) -> JoinHandle> { thread::spawn(move || { - let mut file = File::create(path).ok(); + let mut file = path.and_then(|path| File::create(path).ok()); let mut written = 0usize; let mut truncated = false; let mut buffer = [0u8; 4096]; @@ -1089,12 +1487,12 @@ fn capture_stream( Ok(0) => break, Ok(count) => count, Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, + Err(error) => return Err(error), }; if let Ok(mut captured) = shared.lock() - && captured.len() < MAX_PROCESS_LOG_BYTES + && captured.len() < capture_limit { - let remaining = MAX_PROCESS_LOG_BYTES - captured.len(); + let remaining = capture_limit - captured.len(); captured.extend_from_slice(&buffer[..count.min(remaining)]); } if let Some(file) = file.as_mut() { @@ -1110,6 +1508,7 @@ fn capture_stream( } } } + Ok(()) }) } @@ -1145,12 +1544,55 @@ fn assert_absent_or_empty(path: &Path, label: &str) -> Result<()> { ))) } -fn json_string(value: &Value, pointer: &str) -> Result { +fn require_compatible_connection(connection: &Value) -> Result<()> { + if connection.get("status").and_then(Value::as_str) != Some("compatible") { + return Err(HarnessError::Protocol( + "Runtime connection was not compatible".to_owned(), + )); + } + Ok(()) +} + +fn require_registered_runtime(runtimes: &Value, runtime_id: &str, binding_id: &str) -> Result<()> { + let connected = runtimes + .get("items") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("runtime_id").and_then(Value::as_str) == Some(runtime_id) + && item + .pointer("/management/binding/binding_id") + .and_then(Value::as_str) + == Some(binding_id) + }) + }); + if !connected { + return Err(HarnessError::Protocol( + "Runtime registration did not match the verified binding".to_owned(), + )); + } + Ok(()) +} + +fn require_empty_worker_list(workers: &Value) -> Result<()> { + if workers + .get("items") + .and_then(Value::as_array) + .is_none_or(|items| !items.is_empty()) + { + return Err(HarnessError::Protocol( + "fresh Worker list is not empty or has invalid shape".to_owned(), + )); + } + Ok(()) +} + +fn json_string(value: &Value, pointer: &'static str) -> Result { value .pointer(pointer) .and_then(Value::as_str) .map(str::to_owned) - .ok_or_else(|| HarnessError::Protocol(format!("JSON response omitted {pointer}: {value}"))) + .ok_or_else(|| HarnessError::Protocol(format!("JSON response omitted string at {pointer}"))) } fn url_port(url: &str) -> Result { @@ -1175,7 +1617,357 @@ fn io_error(operation: &str, error: std::io::Error) -> HarnessError { #[cfg(test)] mod startup_address_tests { - use super::parse_address_after; + use super::*; + + // 製品binary providerを呼ばず、同じ所有・収集・cleanup経路へ合成childだけを接続する。 + fn synthetic_environment(artifacts_dir: &Path) -> Result { + let temp_root = tempfile::tempdir()?; + let root = temp_root.path().to_path_buf(); + fs::create_dir_all(artifacts_dir)?; + Ok(WorkerE2eEnvironment { + temp_root: Some(temp_root), + home: root.clone(), + xdg_data_home: root.clone(), + xdg_state_home: root.clone(), + xdg_config_home: root.clone(), + xdg_runtime_dir: root.clone(), + server_data_dir: root.join("server"), + runtime_data_dir: root.join("runtime"), + server_config: root.join("server.toml"), + artifacts_dir: artifacts_dir.to_path_buf(), + workspace_id: None, + runtime_id: "synthetic-runtime".into(), + account_handle: "synthetic-account".into(), + server_url: None, + runtime_url: None, + server: None, + runtime: None, + runtime_shutdown: WorkerProcessShutdownOutcome::NotStarted, + server_shutdown: WorkerProcessShutdownOutcome::NotStarted, + shutdown_error: None, + runtime_public_identity: None, + access_token: None, + client: Client::builder().no_proxy().build().unwrap(), + binaries: WorkerBinaryProviderInfo { + workspace_root: root.clone(), + profile: "synthetic".into(), + binaries: Vec::new(), + }, + root, + }) + } + + fn synthetic_process(root: &Path, name: &str, script: &str) -> Result { + let mut command = Command::new("/bin/sh"); + command + .env_clear() + .current_dir(root) + .stdin(Stdio::null()) + .args(["-c", script]); + ManagedProcess::spawn_captured(name, command, None, None, MAX_COMMAND_OUTPUT_BYTES + 1) + } + + fn assert_secret_free(diagnostic: impl std::fmt::Debug) { + assert!(!format!("{diagnostic:?}").contains("private-test-marker")); + } + + #[test] + fn setup_stdout_is_returned_but_redacted_in_logs_and_nonzero_errors() -> Result<()> { + let artifacts = tempfile::tempdir()?; + let environment = synthetic_environment(artifacts.path())?; + // script自体はsecretの完全値を含めず、stdoutの漏洩だけを検出する。 + let script = "printf 'private-%s' test-marker"; + let output = environment.run_command(Path::new("/bin/sh"), &["-c", script], true)?; + assert_eq!(output.stdout, b"private-test-marker"); + let error = environment + .run_command( + Path::new("/bin/sh"), + &["-c", "printf 'private-%s' test-marker; exit 7"], + true, + ) + .unwrap_err(); + assert!( + matches!(&error, HarnessError::CommandFailed { status, stdout, .. } + if status.code() == Some(7) && stdout == "") + ); + assert_secret_free(&error); + assert_secret_free(error.to_string()); + let log = fs::read_to_string(artifacts.path().join("setup-commands.jsonl"))?; + assert_secret_free(&log); + for line in log.lines() { + let record: Value = serde_json::from_str(line)?; + assert_eq!(record["stdout"], ""); + } + // setup経路はraw stdout/stderr fileを作らない。 + assert_eq!(fs::read_dir(artifacts.path())?.count(), 1); + let report = environment.cleanup()?; + assert!(report.cleanup_success && report.root_cleanup_success); + Ok(()) + } + + #[test] + fn setup_deadline_kills_and_reaps_owned_child_without_logging_stdout() -> Result<()> { + let artifacts = tempfile::tempdir()?; + let environment = synthetic_environment(artifacts.path())?; + let script = "trap '' TERM; printf 'private-%s' test-marker; while :; do :; done"; + let timeout = Duration::from_millis(100); + let mut process = synthetic_process(&environment.root, "setup command", script)?; + let started = Instant::now(); + let error = process.collect_output(timeout).unwrap_err(); + assert!(error.to_string().contains("deadline exceeded")); + assert_secret_free(&error); + assert!(started.elapsed() < timeout + EXIT_WAIT + EXIT_WAIT); + assert!(process.child.try_wait()?.is_some()); + assert!(process.readers.is_empty()); + let error = environment + .run_command_with_timeout(Path::new("/bin/sh"), &["-c", script], true, timeout) + .unwrap_err(); + assert_secret_free(&error); + let log = fs::read_to_string(artifacts.path().join("setup-commands.jsonl"))?; + assert_secret_free(&log); + let record: Value = serde_json::from_str(log.trim())?; + assert_eq!(record["stdout"], ""); + assert!( + record["error"] + .as_str() + .unwrap() + .contains("deadline exceeded") + ); + assert_eq!(fs::read_dir(artifacts.path())?.count(), 1); + Ok(()) + } + + #[test] + fn setup_output_overflow_is_bounded_drained_and_rejected() -> Result<()> { + let root = tempfile::tempdir()?; + let mut process = synthetic_process( + root.path(), + "setup command", + "i=0; while [ \"$i\" -lt 20000 ]; do printf 0123456789abcdef; printf fedcba9876543210 >&2; i=$((i + 1)); done", + )?; + let error = process.collect_output(SETUP_WAIT).unwrap_err(); + assert!(error.to_string().contains("output byte budget exceeded")); + // 両pipeを上限後もdrainしなければこの成功exitまで到達しない。 + assert!(process.child.try_wait()?.unwrap().success()); + assert!(process.readers.is_empty()); + assert_eq!( + process.stdout.lock().unwrap().len(), + MAX_COMMAND_OUTPUT_BYTES + 1 + ); + assert_eq!( + process.stderr.lock().unwrap().len(), + MAX_COMMAND_OUTPUT_BYTES + 1 + ); + assert_eq!(fs::read_dir(root.path())?.count(), 0); + Ok(()) + } + + #[test] + fn setup_output_at_byte_limit_is_returned_without_truncation() -> Result<()> { + let root = tempfile::tempdir()?; + let mut process = synthetic_process( + root.path(), + "setup command", + "i=0; while [ \"$i\" -lt 4096 ]; do printf 0123456789abcdef; i=$((i + 1)); done", + )?; + let output = process.collect_output(SETUP_WAIT)?; + assert!(output.status.success()); + assert_eq!(output.stdout.len(), MAX_COMMAND_OUTPUT_BYTES); + assert!( + output + .stdout + .chunks_exact(16) + .all(|chunk| chunk == b"0123456789abcdef") + ); + assert!(output.stderr.is_empty()); + Ok(()) + } + + #[test] + fn failed_output_reader_rejects_successful_child_without_raw_error() -> Result<()> { + struct FailedReader; + impl Read for FailedReader { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("private-test-marker")) + } + } + let root = tempfile::tempdir()?; + let mut process = synthetic_process(root.path(), "setup command", "exit 0")?; + process.readers.push(capture_stream( + FailedReader, + None, + Arc::new(Mutex::new(Vec::new())), + MAX_COMMAND_OUTPUT_BYTES + 1, + )); + let error = process.collect_output(SETUP_WAIT).unwrap_err(); + assert!(error.to_string().contains("output reader failed")); + assert_secret_free(&error); + assert_secret_free(error.to_string()); + assert!(process.child.try_wait()?.unwrap().success()); + Ok(()) + } + + #[test] + fn unfinished_output_reader_has_bounded_join_and_is_not_success() -> Result<()> { + let root = tempfile::tempdir()?; + let mut process = synthetic_process(root.path(), "setup command", "exit 0")?; + assert!( + process + .wait_until(Instant::now() + EXIT_WAIT)? + .unwrap() + .success() + ); + // 子孫を残さず、pipe保持と同じ未完readerをchannelで決定的に再現する。 + let (release, pending) = std::sync::mpsc::channel::<()>(); + let (finished, completion) = std::sync::mpsc::channel(); + process.readers.push(thread::spawn(move || { + let _ = pending.recv(); + let _ = finished.send(()); + Ok(()) + })); + let timeout = Duration::from_millis(50); + let started = Instant::now(); + let error = process.collect_output(timeout).unwrap_err(); + drop(release); + completion.recv_timeout(EXIT_WAIT).unwrap(); + assert!( + error + .to_string() + .contains("readers did not finish before deadline") + ); + assert!(started.elapsed() < timeout + EXIT_WAIT); + assert!(process.readers.is_empty()); + Ok(()) + } + + #[test] + fn runtime_failure_still_reaps_live_server_and_survives_repeated_shutdown() -> Result<()> { + let artifacts = tempfile::tempdir()?; + let mut environment = synthetic_environment(artifacts.path())?; + let mut runtime = synthetic_process(&environment.root, "runtime", "exit 7")?; + assert_eq!( + runtime + .wait_until(Instant::now() + EXIT_WAIT)? + .unwrap() + .code(), + Some(7) + ); + environment.runtime = Some(runtime); + let server = synthetic_process( + &environment.root, + "server", + "trap 'exit 0' TERM; printf 'ready 127.0.0.1:12345\\n'; while :; do :; done", + )?; + server.wait_for_address("ready ", EXIT_WAIT)?; + environment.server = Some(server); + for _ in 0..2 { + assert!(environment.shutdown().is_err()); + assert!(environment.runtime.is_none() && environment.server.is_none()); + assert!(matches!( + environment.runtime_shutdown, + WorkerProcessShutdownOutcome::Failed { .. } + )); + assert_eq!( + environment.server_shutdown, + WorkerProcessShutdownOutcome::Succeeded + ); + } + let root = environment.root.clone(); + assert!(environment.cleanup().is_err()); + assert!(!root.exists()); + let report: WorkerEnvironmentCleanupReport = + serde_json::from_slice(&fs::read(artifacts.path().join("cleanup.json"))?)?; + assert!(!report.cleanup_success); + assert!(report.root_cleanup_success); + assert!(report.cleanup_error.is_some()); + Ok(()) + } + + #[test] + fn cleanup_and_drop_keep_process_failures_separate_from_root_cleanup() -> Result<()> { + for explicit_cleanup in [false, true] { + for (runtime_code, server_code) in [(0, 0), (7, 0), (0, 9), (7, 9)] { + let artifacts = tempfile::tempdir()?; + let mut environment = synthetic_environment(artifacts.path())?; + for (name, code) in [("runtime", runtime_code), ("server", server_code)] { + let mut process = synthetic_process( + &environment.root, + name, + &format!("printf 'private-%s' test-marker; exit {code}"), + )?; + assert_eq!( + process + .wait_until(Instant::now() + EXIT_WAIT)? + .unwrap() + .code(), + Some(code) + ); + if name == "runtime" { + environment.runtime = Some(process); + } else { + environment.server = Some(process); + } + } + let root = environment.root.clone(); + let expected_success = runtime_code == 0 && server_code == 0; + if explicit_cleanup { + assert_eq!(environment.cleanup().is_ok(), expected_success); + } else { + drop(environment); + } + let raw_report = fs::read_to_string(artifacts.path().join("cleanup.json"))?; + assert_secret_free(&raw_report); + let report: WorkerEnvironmentCleanupReport = serde_json::from_str(&raw_report)?; + assert_eq!(report.cleanup_success, expected_success); + assert_eq!(report.cleanup_error.is_none(), expected_success); + assert!(report.root_cleanup_success); + assert!(report.root_cleanup_error.is_none()); + assert!(!report.fixture_root_exists_after && !root.exists()); + for (outcome, code) in [ + (report.runtime_shutdown, runtime_code), + (report.server_shutdown, server_code), + ] { + if code == 0 { + assert_eq!(outcome, WorkerProcessShutdownOutcome::Succeeded); + } else { + assert!(matches!( + outcome, + WorkerProcessShutdownOutcome::Failed { .. } + )); + } + } + } + } + Ok(()) + } + + #[test] + fn shutdown_rejects_nonzero_exit_even_when_process_already_exited() -> super::Result<()> { + let root = tempfile::tempdir()?; + for code in [0, 7] { + let mut command = std::process::Command::new("/bin/sh"); + command + .env_clear() + .current_dir(root.path()) + .args(["-c", &format!("exit {code}")]); + let mut process = super::ManagedProcess::spawn( + "already-exited", + command, + &root.path().join(format!("{code}.stdout")), + &root.path().join(format!("{code}.stderr")), + )?; + // Reap first to deterministically exercise the initial try_wait branch. + assert_eq!(process.child.wait()?.code(), Some(code)); + let result = process.shutdown(std::time::Duration::from_secs(1)); + if code == 0 { + assert!(result?.success()); + } else { + assert!(matches!(result, Err(super::HarnessError::Protocol(message)) + if message.contains("did not complete graceful shutdown"))); + } + } + Ok(()) + } #[test] fn startup_address_waits_for_complete_valid_socket_record() { @@ -1205,3 +1997,107 @@ mod startup_address_tests { } } } + +#[cfg(test)] +mod response_diagnostic_tests { + use super::*; + use serde_json::json; + use std::io::{Read, Write}; + use std::net::TcpListener; + + fn assert_secret_free(error: &HarnessError) { + for diagnostic in [error.to_string(), format!("{error:?}")] { + assert!(!diagnostic.contains("private-test-marker"), "{diagnostic}"); + } + assert!(std::error::Error::source(error).is_none()); + } + + #[test] + fn json_decode_errors_do_not_retain_rejected_fields_values_or_enum_names() { + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct Document { + #[allow(dead_code)] + count: u64, + } + for raw in [ + r#"{"count":"private-test-marker"}"#, + r#"{"count":1,"private-test-marker":true}"#, + ] { + let error: HarnessError = serde_json::from_str::(raw).unwrap_err().into(); + assert_secret_free(&error); + } + let raw = serde_json::json!({"kind":"private-test-marker"}); + let error: HarnessError = serde_json::from_value::(raw) + .unwrap_err() + .into(); + assert_secret_free(&error); + } + + #[test] + fn valid_json_contract_failures_do_not_retain_response_fields_or_values() -> Result<()> { + let response: Value = serde_json::from_str( + r#"{"status":"private-test-marker","items":[{"private-test-marker":"private-test-marker"}],"binding_id":{ "private-test-marker":true }}"#, + )?; + for error in [ + require_compatible_connection(&response).unwrap_err(), + require_registered_runtime(&response, "runtime-test", "binding-test").unwrap_err(), + require_empty_worker_list(&response).unwrap_err(), + json_string(&response, "/binding_id").unwrap_err(), + json_string(&response, "/workspace/workspace_id").unwrap_err(), + ] { + assert_secret_free(&error); + } + require_compatible_connection(&json!({"status":"compatible"}))?; + require_registered_runtime( + &json!({"items":[{"runtime_id":"runtime-test","management":{"binding":{"binding_id":"binding-test"}}}]}), + "runtime-test", + "binding-test", + )?; + require_empty_worker_list(&json!({"items":[]}))?; + assert_eq!( + json_string(&json!({"id":"worker-test"}), "/id")?, + "worker-test" + ); + Ok(()) + } + + #[test] + fn http_failure_bodies_and_malformed_success_bodies_are_not_diagnostics() -> Result<()> { + for (status, body) in [ + ("400 Bad Request", "private-test-marker"), + ("200 OK", "private-test-marker"), + ] { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let server = thread::spawn(move || -> std::io::Result<()> { + let (mut connection, _) = listener.accept()?; + connection.set_read_timeout(Some(Duration::from_secs(3)))?; + let mut request = [0; 4096]; + connection.read(&mut request)?; + write!( + connection, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + Ok(()) + }); + let response = Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(3)) + .build() + .unwrap() + .get(format!("http://{address}")) + .send() + .unwrap(); + let error = decode_api_response(response, "test response").unwrap_err(); + server.join().unwrap()?; + assert_secret_free(&error); + if status.starts_with("400") { + assert!(error.to_string().contains("HTTP 400")); + } + } + Ok(()) + } +} diff --git a/tests/e2e/src/worker_session.rs b/tests/e2e/src/worker_session.rs new file mode 100644 index 000000000..fb2a454e4 --- /dev/null +++ b/tests/e2e/src/worker_session.rs @@ -0,0 +1,284 @@ +//! Live public Worker protocol assertions. No Submit/Resume/model request is sent. +use std::net::TcpStream; +use std::time::{Duration, Instant}; + +use protocol::{Event, SessionSnapshot, WorkerState}; +use tungstenite::{Message, WebSocket, client::IntoClientRequest}; + +use crate::{HarnessError, Result, WorkerE2eEnvironment}; + +const WS_WAIT: Duration = Duration::from_secs(10); + +pub struct WorkerSessionSubscription { + socket: WebSocket, +} + +impl WorkerSessionSubscription { + pub fn connect( + environment: &WorkerE2eEnvironment, + worker_id: &str, + expected: &SessionSnapshot, + ) -> Result { + let authorized = environment.worker_protocol_request(worker_id)?; + let mut url = authorized.url().clone(); + url.set_scheme("ws") + .map_err(|_| error("invalid protocol URL"))?; + let mut request = url + .as_str() + .into_client_request() + .map_err(|_| error("build protocol handshake"))?; + request.headers_mut().extend(authorized.headers().clone()); + // Only this environment's loopback endpoint is permitted, never a proxy. + let address = url + .socket_addrs(|| None) + .map_err(|_| error("resolve protocol address"))? + .into_iter() + .find(|address| address.ip().is_loopback()) + .ok_or_else(|| error("protocol address is not loopback"))?; + let stream = TcpStream::connect_timeout(&address, WS_WAIT)?; + stream.set_read_timeout(Some(WS_WAIT))?; + stream.set_write_timeout(Some(WS_WAIT))?; + let (socket, _) = + tungstenite::client(request, stream).map_err(|_| error("protocol handshake failed"))?; + let mut subscription = Self { socket }; + let deadline = Instant::now() + WS_WAIT; + loop { + match subscription.read(deadline)? { + Message::Text(text) => { + let raw: serde_json::Value = serde_json::from_str(&text)?; + validate_full_snapshot(&raw, expected)?; + // Only validated public content is retained, never request headers. + std::fs::write( + environment.artifacts_dir.join("restored-session.json"), + serde_json::to_vec_pretty(&raw)?, + )?; + return Ok(subscription); + } + Message::Ping(_) => subscription + .socket + .flush() + .map_err(|_| error("flush protocol Pong"))?, + Message::Pong(_) => {} + _ => return Err(error("protocol ended before its initial full Snapshot")), + } + } + } + + /// Prove an already-open subscription remains usable after the other + /// environment is stopped and cleaned up. This does not invoke the model. + pub fn assert_alive(&mut self) -> Result<()> { + let payload = b"restore-e2e-isolation".to_vec(); + self.socket + .send(Message::Ping(payload.clone().into())) + .map_err(|_| error("send protocol Ping"))?; + let deadline = Instant::now() + WS_WAIT; + loop { + match self.read(deadline)? { + Message::Pong(bytes) if bytes.as_ref() == payload.as_slice() => return Ok(()), + Message::Ping(_) => self + .socket + .flush() + .map_err(|_| error("flush protocol Pong"))?, + Message::Pong(_) => {} + _ => { + return Err(error( + "idle subscription changed or ended during isolation check", + )); + } + } + } + } + + /// A public stop must terminate the connected execution, not merely update + /// a retained catalog row. Require the shutdown event or websocket close. + pub fn expect_stopped(mut self) -> Result<()> { + let deadline = Instant::now() + WS_WAIT; + loop { + match self.read(deadline)? { + Message::Close(_) => return Ok(()), + Message::Text(text) => match serde_json::from_str::(&text)? { + Event::Shutdown => return Ok(()), + Event::WorkerState { .. } => {} + _ => return Err(error("unexpected protocol event after Worker stop")), + }, + Message::Ping(_) => self + .socket + .flush() + .map_err(|_| error("flush protocol Pong"))?, + Message::Pong(_) => {} + _ => return Err(error("unexpected protocol frame after Worker stop")), + } + } + } + + fn read(&mut self, deadline: Instant) -> Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| error("protocol deadline exceeded"))?; + self.socket.get_mut().set_read_timeout(Some(remaining))?; + self.socket + .read() + .map_err(|_| error("protocol read failed or timed out")) + } +} + +fn error(message: &str) -> HarnessError { + HarnessError::Protocol(message.into()) +} + +pub fn validate_full_snapshot(raw: &serde_json::Value, expected: &SessionSnapshot) -> Result<()> { + let event: Event = serde_json::from_value(raw.clone())?; + // Product DTOs intentionally tolerate unknown fields for compatibility. This + // fixture boundary does not: anything discarded by typed decoding must never + // be persisted to an artifact, including fields outside the Session payload. + if serde_json::to_value(&event)? != *raw { + return Err(error( + "noncanonical or unknown fields in full protocol Snapshot envelope", + )); + } + let Event::Snapshot { + session, + state, + in_flight, + internal_workers, + .. + } = event + else { + return Err(error("first protocol event is not a full Snapshot")); + }; + if state.state != WorkerState::Idle || !in_flight.is_empty() || !internal_workers.is_empty() { + return Err(error( + "restored Worker is not an idle, completed standalone Session", + )); + } + // Typed equality covers entry order/ID/timestamp/provenance/content; raw + // equality also refuses unknown fields that could leak private data. + if session != *expected + || raw.pointer("/data/session") != Some(&serde_json::to_value(expected)?) + { + return Err(error( + "full Session snapshot differs from supplied expected rows", + )); + } + if !session.pending_submissions.submissions.is_empty() { + return Err(error("fixture has pending submissions")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use protocol::{ + Greeting, InFlightSnapshot, Segment, SessionEntryProvenance, SessionSnapshotEntry, + SessionSnapshotEntryData, WorkerStateSnapshot, WorkerStatus, + }; + + // Protocol-validation samples only, not a substitute saved Worker fixture. + fn sample() -> (serde_json::Value, SessionSnapshot) { + let session = SessionSnapshot { + pending_submissions: Default::default(), + entries: vec![SessionSnapshotEntry { + entry_id: "E-unit-1".into(), + timestamp: 123, + provenance: SessionEntryProvenance::HumanInput, + derived_from: vec![], + use_policy: None, + data: SessionSnapshotEntryData::UserInput { + segments: vec![Segment::text("unit input")], + }, + }], + }; + let event = Event::Snapshot { + session: session.clone(), + state: WorkerStateSnapshot::from(WorkerStatus::Idle), + in_flight: InFlightSnapshot::default(), + internal_workers: vec![], + greeting: Greeting { + worker_name: "unit".into(), + cwd: ".".into(), + provider: "unit".into(), + model: "unit".into(), + reasoning: None, + scope_summary: "none".into(), + tools: vec![], + context_window: 0, + context_tokens: 0, + context_usage: None, + }, + }; + (serde_json::to_value(event).unwrap(), session) + } + + #[test] + fn full_snapshot_requires_exact_ids_timestamps_contents_and_no_unknown_fields() { + let (raw, expected) = sample(); + validate_full_snapshot(&raw, &expected).unwrap(); + for (key, value) in [ + ("entry_id", serde_json::json!("different")), + ("timestamp", serde_json::json!(124)), + ( + "hidden_reasoning", + serde_json::json!("must never be exposed"), + ), + ("segments", serde_json::json!([])), + ] { + let mut changed = raw.clone(); + changed["data"]["session"]["entries"][0][key] = value; + assert!( + validate_full_snapshot(&changed, &expected).is_err(), + "{key}" + ); + } + let mut missing = raw.clone(); + missing["data"]["session"]["entries"] = serde_json::json!([]); + assert!(validate_full_snapshot(&missing, &expected).is_err()); + let mut extra = raw; + let entry = extra["data"]["session"]["entries"][0].clone(); + extra["data"]["session"]["entries"] + .as_array_mut() + .unwrap() + .push(entry); + assert!(validate_full_snapshot(&extra, &expected).is_err()); + } + + #[test] + fn full_snapshot_rejects_unknown_fields_outside_session_before_artifact_write() { + let (raw, expected) = sample(); + for pointer in [ + "", + "/data", + "/data/greeting", + "/data/state", + "/data/state/state", + ] { + let mut changed = raw.clone(); + changed + .pointer_mut(pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert( + "hidden_reasoning".into(), + serde_json::json!("must not reach artifact"), + ); + assert!( + validate_full_snapshot(&changed, &expected).is_err(), + "{pointer}" + ); + } + let mut changed = raw; + changed["data"]["greeting"]["private_key"] = serde_json::json!("must not reach artifact"); + assert!(validate_full_snapshot(&changed, &expected).is_err()); + } + + #[test] + fn non_snapshot_event_cannot_stand_in_for_live_snapshot() { + let (_, expected) = sample(); + assert!( + validate_full_snapshot(&serde_json::to_value(Event::Shutdown).unwrap(), &expected) + .is_err() + ); + } +} diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 058e27827..87636bb62 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -41,13 +41,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_ &event.data, &ready_ticket_id, "Planning E2E Ticket A", - "planning", + "open", ) && has_backend_ticket( &event.data, &planning_ticket_id, "Planning E2E Ticket B", - "planning", + "open", ) }, )?; @@ -82,13 +82,13 @@ fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_ &event.data, &ready_ticket_id, "Planning E2E Ticket A", - "planning", + "open", ) && has_backend_ticket( &event.data, &planning_ticket_id, "Planning E2E Ticket B", - "planning", + "open", ) }, )?; @@ -137,7 +137,7 @@ fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e:: &event.data, &ready_ticket_id, "Planning E2E Ticket A", - "planning", + "open", ) }, )?; diff --git a/tests/e2e/tests/worker_restore.rs b/tests/e2e/tests/worker_restore.rs new file mode 100644 index 000000000..3d1619847 --- /dev/null +++ b/tests/e2e/tests/worker_restore.rs @@ -0,0 +1,274 @@ +//! Fixed, externally supplied Worker restore. Never generates a fixture or sends model input. +#[path = "worker_restore/fixture.rs"] +mod fixture; +#[path = "worker_restore/seed.rs"] +mod seed; + +use std::fs; +use std::time::{Duration, Instant}; + +use fixture::{Fixture, failure}; +use seed::Installed; +use server_api::{ + RuntimeWorkerLifecycleResult, RuntimeWorkerShowResponse, WorkerListResponse, + WorkerOperationState, WorkerRestoreResponse, WorkerRestoreState, WorkerSummary, +}; +use yoi_e2e::{FixtureSource, Result, WorkerE2eEnvironment, WorkerSessionSubscription}; + +#[test] +fn supplied_stopped_worker_restores_exact_live_session_and_cleans_up() -> Result<()> { + let fixture = Fixture::load()?; + let result = (|| { + let installed = seed::start(&fixture)?; + let subscription = restore(&installed, &fixture, "fixed-fixture-restore-single")?; + stop_and_cleanup(installed, vec![subscription], &fixture) + })(); + fixture.source.assert_unchanged()?; + result +} + +#[test] +fn concurrent_fixed_id_restores_keep_sessions_storage_and_cleanup_isolated() -> Result<()> { + let fixture = Fixture::load()?; + let result = (|| { + let (first, second) = std::thread::scope(|scope| { + let first = scope.spawn(|| seed::start(&fixture)); + let second = seed::start(&fixture); + ( + first + .join() + .map_err(|_| failure("first environment startup panicked")) + .and_then(|result| result), + second, + ) + }); + let first = first?; + let second = second?; + assert_eq!( + first.environment.workspace_id, + second.environment.workspace_id + ); + assert_eq!(first.environment.runtime_id, second.environment.runtime_id); + assert_ne!(first.environment.root, second.environment.root); + assert_ne!( + first.environment.server_data_dir, + second.environment.server_data_dir + ); + assert_ne!( + first.environment.runtime_data_dir, + second.environment.runtime_data_dir + ); + assert_ne!( + first.environment.artifacts_dir, + second.environment.artifacts_dir + ); + assert_ne!( + first.environment.account_handle, + second.environment.account_handle + ); + assert_ne!(first.runtime_public_key, second.runtime_public_key); + assert_ne!(first.workspace_public_key, second.workspace_public_key); + assert_ne!( + first.environment.server_port()?, + second.environment.server_port()? + ); + assert_ne!( + first.environment.runtime_port()?, + second.environment.runtime_port()? + ); + assert_ne!( + first.environment.server_pid(), + second.environment.server_pid() + ); + assert_ne!( + first.environment.runtime_pid(), + second.environment.runtime_pid() + ); + first + .environment + .assert_authentication_isolated_from(&second.environment)?; + second + .environment + .assert_authentication_isolated_from(&first.environment)?; + let (first_subscription, second_subscription) = std::thread::scope(|scope| { + // Deliberately reuse request identity in isolated stores, just like fixed IDs. + let a = scope.spawn(|| restore(&first, &fixture, "fixed-fixture-restore-concurrent")); + let b = restore(&second, &fixture, "fixed-fixture-restore-concurrent"); + ( + a.join() + .map_err(|_| failure("first restore panicked")) + .and_then(|result| result), + b, + ) + }); + let first_subscription = first_subscription?; + let mut second_subscription = second_subscription?; + let second_session_root = second + .environment + .runtime_data_dir + .join("runtime/workers") + .join(&fixture.manifest.worker_id) + .join("session"); + let second_session = FixtureSource::capture(&second_session_root)?; + let first_root = first.environment.root.clone(); + let first_artifacts = first.environment.artifacts_dir.clone(); + stop_and_cleanup(first, vec![first_subscription], &fixture)?; + assert!(!first_root.exists()); + assert!(first_artifacts.join("cleanup.json").is_file()); + assert!(second.environment.root.is_dir()); + assert!(second.environment.server_data_dir.is_dir()); + assert!(second.environment.runtime_data_dir.is_dir()); + assert!( + second + .environment + .artifacts_dir + .join("restored-session.json") + .is_file() + ); + second.environment.assert_connected()?; + assert_single_state(&second.environment, &fixture, "idle")?; + second_subscription.assert_alive()?; + // A second live connection independently verifies content after peer cleanup. + let mut reconnected = WorkerSessionSubscription::connect( + &second.environment, + &fixture.manifest.worker_id, + &second.snapshot, + )?; + reconnected.assert_alive()?; + second_session.assert_unchanged()?; + stop_and_cleanup(second, vec![second_subscription, reconnected], &fixture)?; + Ok(()) + })(); + fixture.source.assert_unchanged()?; + result +} + +fn restore( + installed: &Installed, + fixture: &Fixture, + request_id: &str, +) -> Result { + phase(&installed.environment, "observe-stopped")?; + let stopped = assert_single_state(&installed.environment, fixture, "stopped")?; + let token = stopped + .restore_observation_token + .as_deref() + .filter(|token| !token.is_empty()) + .ok_or_else(|| failure("stopped Worker did not expose a restore observation token"))?; + phase(&installed.environment, "restore")?; + let result: WorkerRestoreResponse = serde_json::from_value( + installed + .environment + .restore_worker(&fixture.manifest.worker_id, request_id, token)?, + )?; + if result.workspace_id != fixture.manifest.workspace_id + || result.runtime_id != fixture.manifest.runtime_id + || result.worker_id != fixture.manifest.worker_id + || result.result.state != WorkerRestoreState::Accepted + { + return Err(failure(format!( + "restore did not commit accepted: {:?}", + result.result.state + ))); + } + assert_single_state(&installed.environment, fixture, "idle")?; + let detail: RuntimeWorkerShowResponse = serde_json::from_value( + installed + .environment + .worker_detail(&fixture.manifest.worker_id)?, + )?; + if detail.worker.worker_id != fixture.manifest.worker_id + || detail.worker.runtime_id != fixture.manifest.runtime_id + || detail.worker.state != "idle" + || detail + .worker + .worker_state + .as_ref() + .is_none_or(|state| state.state != protocol::WorkerState::Idle) + { + return Err(failure( + "restored Worker detail is not the single live idle execution", + )); + } + phase(&installed.environment, "subscribe-live-session")?; + let mut subscription = WorkerSessionSubscription::connect( + &installed.environment, + &fixture.manifest.worker_id, + &installed.snapshot, + )?; + subscription.assert_alive()?; + phase(&installed.environment, "live-session-verified")?; + Ok(subscription) +} + +fn assert_single_state( + environment: &WorkerE2eEnvironment, + fixture: &Fixture, + state: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let list: WorkerListResponse = serde_json::from_value(environment.list_workers()?)?; + if list.workspace_id != fixture.manifest.workspace_id || list.items.len() > 1 { + return Err(failure( + "Workspace Worker list has wrong identity or duplicate Workers", + )); + } + if let Some(worker) = list.items.into_iter().next() { + if worker.worker_id != fixture.manifest.worker_id + || worker.runtime_id != fixture.manifest.runtime_id + { + return Err(failure("Workspace listed an unexpected Worker identity")); + } + if worker.state == state { + return Ok(worker); + } + } + if Instant::now() >= deadline { + return Err(failure(format!( + "Worker list did not become exactly one {state} Worker" + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn stop_and_cleanup( + installed: Installed, + subscriptions: Vec, + fixture: &Fixture, +) -> Result<()> { + phase(&installed.environment, "stop")?; + let result: RuntimeWorkerLifecycleResult = serde_json::from_value( + installed + .environment + .stop_worker(&fixture.manifest.worker_id)?, + )?; + if result.state != WorkerOperationState::Accepted { + return Err(failure("Worker stop was not accepted")); + } + installed.environment.assert_connected()?; + for subscription in subscriptions { + subscription.expect_stopped()?; + // A transport close caused by Server/Runtime shutdown is not stop evidence. + installed.environment.assert_connected()?; + } + assert_single_state(&installed.environment, fixture, "stopped")?; + phase(&installed.environment, "shutdown-cleanup")?; + let report = installed.environment.cleanup()?; + if !report.cleanup_success || report.fixture_root_exists_after { + return Err(failure("environment cleanup failed")); + } + if !report.report_path.is_file() { + return Err(failure("cleanup evidence is missing")); + } + Ok(()) +} + +fn phase(environment: &WorkerE2eEnvironment, phase: &str) -> Result<()> { + fs::write( + environment.artifacts_dir.join("restore-phase.json"), + serde_json::to_vec_pretty(&serde_json::json!({"phase":phase}))?, + )?; + Ok(()) +} diff --git a/tests/e2e/tests/worker_restore/fixture.rs b/tests/e2e/tests/worker_restore/fixture.rs new file mode 100644 index 000000000..707a159fe --- /dev/null +++ b/tests/e2e/tests/worker_restore/fixture.rs @@ -0,0 +1,257 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use protocol::{SessionMessageRole, SessionSnapshot, SessionSnapshotEntryData}; +use serde::Deserialize; +use serde_json::{Value, json}; +use worker_runtime::test_support::StoppedWorkerTemplate; +use yoi_e2e::{FixtureSource, HarnessError, Result}; + +pub const PROFILE: &str = "project:fixture-chat"; +const ASSETS: [&str; 3] = [ + "expected.json", + "worker-template.json", + "workspace-config.dcdl", +]; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub format_version: u32, + #[serde(rename = "source_revision")] + pub source_commit: String, + pub runtime_schema_version: u32, + pub session_schema_version: u32, + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub session_id: String, + pub segment_id: String, + pub model: String, + pub state: String, + pub acquisition: String, + pub source_file_sha256: BTreeMap, + pub files: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Asset { + pub bytes: u64, + pub sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Expected { + #[serde(rename = "source_revision")] + source_commit: String, + model: String, + worker_id: String, + session_id: String, + rows: Vec, +} + +pub struct Fixture { + pub root: PathBuf, + pub source: FixtureSource, + pub manifest: Manifest, + expected: Expected, +} + +pub fn failure(message: impl Into) -> HarnessError { + HarnessError::Protocol(message.into()) +} +pub fn support(result: std::result::Result) -> Result { + result.map_err(|error| failure(error.to_string())) +} + +impl Fixture { + pub fn load() -> Result { + // Candidate override is explicit and read-only. Missing assets are failures, + // never skips, and no test generates or repairs a fixture. + let root = std::env::var_os("YOI_E2E_RESTORE_FIXTURE") + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join(path) + } + }) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/worker-codex-v1") + }); + let file_names: Vec<_> = std::iter::once("manifest.json").chain(ASSETS).collect(); + let source = FixtureSource::capture_flat(&root, &file_names)?; + let manifest: Manifest = + serde_json::from_slice(&bounded_read(&root.join("manifest.json"))?)?; + if manifest.format_version != 1 + || manifest.state != "stopped" + || manifest.runtime_schema_version == 0 + || manifest.session_schema_version == 0 + || manifest.acquisition.is_empty() + || manifest.source_file_sha256.is_empty() + || manifest.source_commit.len() != 40 + || !manifest + .source_commit + .bytes() + .all(|c| c.is_ascii_hexdigit()) + { + return Err(failure("unsupported/incomplete fixture manifest")); + } + let names: BTreeSet<_> = manifest.files.keys().map(String::as_str).collect(); + if names != BTreeSet::from(ASSETS) { + return Err(failure("unexpected fixture asset set")); + } + for (name, asset) in &manifest.files { + if asset.bytes != fs::metadata(root.join(name))?.len() + || source.hashes().get(Path::new(name)) != Some(&format!("sha256:{}", asset.sha256)) + { + return Err(failure(format!("fixture asset hash/size mismatch: {name}"))); + } + } + let expected: Expected = + serde_json::from_slice(&bounded_read(&root.join("expected.json"))?)?; + if expected.source_commit != manifest.source_commit + || expected.model != manifest.model + || expected.worker_id != manifest.worker_id + || expected.session_id != manifest.session_id + || expected.rows.is_empty() + { + return Err(failure( + "expected conversation identity differs from manifest", + )); + } + verify_builtin_commit(&manifest.source_commit)?; + let fixture = Self { + root, + source, + manifest, + expected, + }; + fixture.template(&fixture.root)?; + fixture.source.assert_unchanged()?; + Ok(fixture) + } + + pub fn template(&self, copy: &Path) -> Result { + let bytes = bounded_read(©.join("worker-template.json"))?; + let template: StoppedWorkerTemplate = serde_json::from_slice(&bytes)?; + let shape: Value = serde_json::from_slice(&bytes)?; + let session_manifest_bytes: Vec = + serde_json::from_value(shape["session_manifest"].clone())?; + let segment_bytes: Vec = serde_json::from_value(shape["segment_jsonl"].clone())?; + for (name, bytes) in [ + ( + "session/session.json".to_owned(), + session_manifest_bytes.as_slice(), + ), + ( + format!("session/segments/{}.jsonl", self.manifest.segment_id), + segment_bytes.as_slice(), + ), + ] { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if self.manifest.source_file_sha256.get(&name) != Some(&digest) { + return Err(failure( + "exported Session bytes differ from acquisition hash evidence", + )); + } + } + let session_manifest: Value = serde_json::from_slice(&session_manifest_bytes)?; + let m = &self.manifest; + if template.workspace_id() != m.workspace_id + || template.runtime_id() != m.runtime_id + || template.worker_id().to_string() != m.worker_id + || template.session_id().to_string() != m.session_id + || template.segment_id().to_string() != m.segment_id + || shape["runtime_schema_version"] != m.runtime_schema_version + || session_manifest["schema_version"] != m.session_schema_version + { + return Err(failure("template schema/identity differs from manifest")); + } + Ok(template) + } + + pub fn verify_conversation(&self, snapshot: &SessionSnapshot) -> Result<()> { + let mut rows = Vec::new(); + for entry in &snapshot.entries { + let (role, parts) = match &entry.data { + SessionSnapshotEntryData::UserInput { segments } => { + ("user", serde_json::to_value(segments)?) + } + SessionSnapshotEntryData::Message { + role: SessionMessageRole::Assistant, + content, + } => ("assistant", serde_json::to_value(content)?), + // Display-only accounting is not a conversation row. Its exact + // IDs, timestamp and values are still checked in the full WS snapshot. + SessionSnapshotEntryData::RunStats { .. } => continue, + _ => { + return Err(failure( + "unexpected non-conversation entry in full Session snapshot", + )); + } + }; + rows.push(json!({"entry_id":entry.entry_id,"timestamp":entry.timestamp,"role":role,"parts":parts})); + } + if rows != self.expected.rows { + return Err(failure( + "full public conversation rows differ from expected IDs/time/roles/text/order", + )); + } + Ok(()) + } +} + +fn bounded_read(path: &Path) -> Result> { + FixtureSource::read_bounded(path) +} + +fn verify_builtin_commit(source_commit: &str) -> Result<()> { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + // Resolve the immutable commit before diff; unavailable commits must not be treated + // as an empty diff. All resources are checked, including transitive imports. + let commit = Command::new("git") + .args(["cat-file", "-t", source_commit]) + .current_dir(workspace) + .output()?; + if !commit.status.success() || commit.stdout != b"commit\n" { + return Err(failure( + "fixture source commit is unavailable for builtin verification", + )); + } + let status = Command::new("git") + .args([ + "diff", + "--quiet", + source_commit, + "--", + "resources", + "crates/builtin-source", + ]) + .current_dir(workspace) + .status()?; + if !status.success() { + return Err(failure( + "builtin/config resources differ from fixture source commit; provide a new version or frozen references", + )); + } + Ok(()) +} diff --git a/tests/e2e/tests/worker_restore/seed.rs b/tests/e2e/tests/worker_restore/seed.rs new file mode 100644 index 000000000..afc7804e4 --- /dev/null +++ b/tests/e2e/tests/worker_restore/seed.rs @@ -0,0 +1,482 @@ +use std::fs; +use std::net::TcpListener; +use std::path::Path; +use std::sync::Arc; + +use config_source::{ConfigTreeChange, VirtualPath}; +use protocol::SessionSnapshot; +use serde_json::Value; +use worker_runtime::FsRuntimeStoreOptions; +use worker_runtime::catalog::{ + ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource, + WorkspaceApiRef, +}; +use yoi_e2e::{Result, WorkerE2eEnvironment}; +use yoi_workspace_server::auth::{mint_secret, new_id, now_rfc3339, token_hash}; +use yoi_workspace_server::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaRegistry}; +use yoi_workspace_server::hosts::{ + WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, +}; +use yoi_workspace_server::profile_settings::{ + ProfileConfigSchemaProvider, build_virtual_profile_config_bundle, + project_profiles_from_workspace_config, +}; +use yoi_workspace_server::store::{ + AccountRecord, ApiTokenRecord, UserRecord, WorkspaceBootstrapRecord, + WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, +}; +use yoi_workspace_server::test_support::{FixedWorkerSeed, seed_fixed_worker}; +use yoi_workspace_server::workspace_signing_identity::{ + FsWorkspaceSigningMaterialStore, WorkspaceSigningIdentityService, + workspace_signing_material_root, +}; +use yoi_workspace_server::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; + +use super::fixture::{Fixture, PROFILE, failure, support}; + +pub struct Installed { + pub environment: WorkerE2eEnvironment, + pub snapshot: SessionSnapshot, + pub runtime_public_key: String, + pub workspace_public_key: String, +} + +pub fn start(fixture: &Fixture) -> Result { + let mut environment = WorkerE2eEnvironment::new()?; + super::phase(&environment, "fixture-copy-verify")?; + let copy = environment.root.join("provided-fixture"); + fixture.source.copy_to(©)?; + fs::write( + environment.artifacts_dir.join("fixture-manifest.json"), + fs::read(copy.join("manifest.json"))?, + )?; + let template = fixture.template(©)?; + let runtime_identity = + environment.initialize_runtime_identity(fixture.manifest.runtime_id.clone())?; + let runtime_public_key = runtime_identity["public_key"] + .as_str() + .ok_or_else(|| failure("Runtime public bundle omitted key"))? + .to_owned(); + + // Reserve an environment-local placeholder endpoint only while the Runtime is + // offline. Its real :0 endpoint replaces this binding through public API CAS. + let reserved_endpoint = TcpListener::bind("127.0.0.1:0")?; + super::phase( + &environment, + "offline-backend-bootstrap-profile-worker-seed", + )?; + let bootstrap = bootstrap( + &environment, + fixture, + ©, + &runtime_identity, + &format!("http://{}", reserved_endpoint.local_addr()?), + &template.worker_name(), + )?; + // Close the placeholder before Backend probes it: an accepting TCP listener + // with no HTTP service would stall discovery until its transport timeout. + drop(reserved_endpoint); + super::phase(&environment, "start-seeded-backend")?; + environment.start_seeded_backend( + fixture.manifest.workspace_id.clone(), + fixture.manifest.runtime_id.clone(), + bootstrap.token, + )?; + let mut snapshot = None; + super::phase(&environment, "initialize-runtime-trust")?; + environment.start_runtime_with_seed(|environment| { + super::phase(environment, "install-runtime-fixture-and-validate-session")?; + let options = FsRuntimeStoreOptions::new(environment.runtime_data_dir.join("runtime")) + .with_runtime_id(&fixture.manifest.runtime_id); + let request = CreateWorkerRequest { + worker_id: template.worker_id(), + create_fingerprint: bootstrap.create_fingerprint.clone(), + profile: ProfileSelector::Named(PROFILE.into()), + display_name: Some("Restore E2E fixture".into()), + profile_source: ProfileSourceArchiveSource::WorkspaceConfig { + archive: bootstrap.archive.clone(), + }, + config_bundle: Some(bootstrap.config_bundle.clone()), + workspace_api: Some(WorkspaceApiRef { + workspace_id: fixture.manifest.workspace_id.clone(), + base_url: environment.backend_target()?.0.into(), + }), + initial_input: None, + workdir_attachment_requests: vec![], + workdir_attachments: vec![], + worker_observation_enabled: false, + worker_observation_grants: vec![], + memory_settings: Some(bootstrap.memory_settings.clone()), + subjektiv_attached: false, + backend_job: None, + }; + support(template.install(&options, request, &bootstrap.manifest))?; + let sessions = support(session_store::WorkerSessionStore::open_read_only( + options + .root + .join("workers") + .join(template.worker_id().to_string()) + .join("session"), + ))?; + let (logs, _) = support(sessions.read_all_read_only_bounded( + template.session_id(), + template.segment_id(), + 8 * 1024 * 1024, + ))?; + let projected = session_store::project_session_snapshot(template.session_id(), &logs); + fixture.verify_conversation(&projected)?; + snapshot = Some(full_restored_snapshot(&sessions, &logs)); + Ok(()) + })?; + fixture.source.assert_unchanged()?; + Ok(Installed { + environment, + snapshot: snapshot.ok_or_else(|| failure("Runtime seed was not invoked"))?, + runtime_public_key, + workspace_public_key: bootstrap.workspace_public_key, + }) +} + +fn full_restored_snapshot( + store: &dyn session_store::Store, + logs: &[session_store::LogEntry], +) -> SessionSnapshot { + session_store::public_snapshot::project_current_session_snapshot_with_accounting(store, logs) +} + +#[cfg(test)] +mod tests { + use super::*; + use protocol::{ + Event, Greeting, InFlightSnapshot, InvokeKind, SessionSnapshotEntryData, + WorkerStateSnapshot, WorkerStatus, + }; + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + }; + + #[test] + fn completed_run_full_snapshot_retains_invoke_and_usage_accounting() -> Result<()> { + // Projection regression only: no saved Worker or substitute E2E fixture. + let temp = tempfile::tempdir()?; + let store = support(session_store::WorkerSessionStore::new(temp.path()))?; + let session_id = session_store::new_session_id(); + let logs = vec![ + LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }, + LogEntry::Invoke { + ts: 10, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 20, + entry: LoggedHistoryEntry { + item: LoggedItem::Message { + role: LoggedRole::Assistant, + content: vec![LoggedContentPart::Text { + text: "unit response".into(), + }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId("unit-response".into()), + origin: LoggedSessionHistoryOrigin::LegacyUnknown, + derivation: None, + input_metadata: None, + }, + }, + }, + LogEntry::LlmUsage { + ts: 30, + history_len: 1, + input_total_tokens: 100, + cache_read_tokens: 20, + cache_write_tokens: 0, + output_tokens: 5, + }, + serde_json::from_value(serde_json::json!({"kind":"run_completed", "ts":40, + "interrupted":false,"result":"finished"}))?, + ]; + let conversation = session_store::project_session_snapshot(session_id, &logs); + let expected = full_restored_snapshot(&store, &logs); + assert_eq!(expected.entries.len(), conversation.entries.len() + 2); + assert!(matches!( + expected.entries[expected.entries.len() - 2].data, + SessionSnapshotEntryData::Invoke { + trigger: InvokeKind::UserSend + } + )); + assert_eq!(expected.entries[expected.entries.len() - 2].timestamp, 10); + assert!(matches!( + expected.entries.last().unwrap().data, + SessionSnapshotEntryData::Usage { + input_tokens: 100, + cache_read_input_tokens: 20, + output_tokens: 5 + } + )); + assert_eq!(expected.entries.last().unwrap().timestamp, 30); + let live = session_store::public_snapshot::project_current_session_snapshot_with_accounting( + &store, &logs, + ); + let event = Event::Snapshot { + session: live, + state: WorkerStateSnapshot::from(WorkerStatus::Idle), + in_flight: InFlightSnapshot::default(), + internal_workers: vec![], + greeting: Greeting { + worker_name: "unit".into(), + cwd: ".".into(), + provider: "unit".into(), + model: "unit".into(), + reasoning: None, + scope_summary: "none".into(), + tools: vec![], + context_window: 0, + context_tokens: 0, + context_usage: None, + }, + }; + let raw = serde_json::to_value(event)?; + yoi_e2e::validate_full_snapshot(&raw, &expected)?; + assert!(yoi_e2e::validate_full_snapshot(&raw, &conversation).is_err()); + Ok(()) + } +} + +struct Bootstrap { + token: String, + workspace_public_key: String, + create_fingerprint: String, + archive: worker_runtime::profile_archive::ProfileSourceArchiveRef, + config_bundle: ConfigBundleRef, + manifest: manifest::WorkerManifest, + memory_settings: manifest::WorkspaceMemorySettingsSnapshot, +} + +fn bootstrap( + environment: &WorkerE2eEnvironment, + fixture: &Fixture, + copy: &Path, + runtime_identity: &Value, + endpoint: &str, + worker_name: &str, +) -> Result { + fs::create_dir_all(&environment.server_data_dir)?; + let database = environment.server_data_dir.join("server.db"); + if database.exists() { + return Err(failure("Backend seed refuses an existing database")); + } + let store = support(SqliteWorkspaceStore::open(&database))?; + let now = now_rfc3339(); + let account_id = new_id("acct-user"); + let user_id = new_id("user"); + support(store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".into(), + handle: environment.account_handle.clone(), + display_name: "Restore E2E owner".into(), + created_at: now.clone(), + updated_at: now.clone(), + }))?; + support(store.upsert_user(&UserRecord { + user_id: user_id.clone(), + account_id: account_id.clone(), + handle: environment.account_handle.clone(), + display_name: "Restore E2E owner".into(), + created_at: now.clone(), + updated_at: now.clone(), + }))?; + let token = mint_secret("yoi_api"); + support(store.create_api_token(&ApiTokenRecord { + token_id: new_id("api-token"), + token_hash: token_hash(&token), + user_id, + label: "restore-e2e".into(), + created_at: now.clone(), + expires_at: None, + revoked_at: None, + last_used_at: None, + }))?; + let identities = WorkspaceSigningIdentityService::new( + Arc::new(store.clone()), + Arc::new(FsWorkspaceSigningMaterialStore::new( + workspace_signing_material_root(&database), + )), + ); + let (identity, operation) = support(identities.prepare_workspace_creation( + "restore-e2e-bootstrap", + "restore-e2e-bootstrap", + &fixture.manifest.workspace_id, + &account_id, + ))?; + let workspace_public_key = identity.public_key.clone(); + support(store.create_workspace_bootstrap( + &WorkspaceBootstrapRecord { + operation_key: "restore-e2e-bootstrap".into(), + request_fingerprint: "restore-e2e-bootstrap".into(), + workspace: WorkspaceRecord { + workspace_id: fixture.manifest.workspace_id.clone(), + owner_account_id: account_id, + display_name: "Restore E2E".into(), + state: "active".into(), + created_at: now.clone(), + updated_at: now.clone(), + }, + repository: None, + }, + &identity, + &operation, + ))?; + support( + store.upsert_workspace_runtime_binding( + WorkspaceRuntimeBinding { + workspace_id: fixture.manifest.workspace_id.clone(), + runtime_id: fixture.manifest.runtime_id.clone(), + display_name: format!("E2E {}", fixture.manifest.runtime_id), + base_url: endpoint.into(), + public_key: runtime_identity["public_key"] + .as_str() + .ok_or_else(|| failure("missing Runtime public key"))? + .into(), + public_key_fingerprint: String::new(), + binding_id: String::new(), + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + created_at: now.clone(), + updated_at: now.clone(), + revoked_at: None, + }, + false, + ), + )?; + + let schema = support( + WorkspaceConfigSchemaRegistry::default() + .with_provider(Arc::new(ProfileConfigSchemaProvider)) + .with_provider(Arc::new( + yoi_workspace_server::prompt_settings::PromptConfigSchemaProvider, + )) + .with_provider(Arc::new( + yoi_workspace_server::runtime_settings::RuntimeConfigSchemaProvider, + )) + .with_provider(Arc::new( + yoi_workspace_server::repository_access::RepositoryAccessConfigSchemaProvider, + )) + .with_provider(Arc::new( + yoi_workspace_server::skills::SkillConfigSchemaProvider, + )) + .compose(), + )?; + let state = support(store.ensure_workspace_config_materialized_with_schema( + &fixture.manifest.workspace_id, + &now, + schema.clone(), + ))?; + let main = support(VirtualPath::parse("main.dcdl"))?; + let current = state + .snapshot + .get(&main) + .ok_or_else(|| failure("Workspace config omitted main.dcdl"))?; + let candidate = support(store.evaluate_workspace_config_candidate_with_schema( + &fixture.manifest.workspace_id, + &ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + changes: vec![ConfigTreeChange::Update { + path: main.clone(), + expected_digest: current.content_digest.clone(), + content: fs::read_to_string(copy.join("workspace-config.dcdl"))?, + }], + entrypoints: vec![main], + }, + schema, + ))?; + let state = support( + store.commit_evaluated_workspace_config(&fixture.manifest.workspace_id, &candidate), + )?; + let projection = support(project_profiles_from_workspace_config( + &fixture.manifest.workspace_id, + &state, + ))?; + let bundle = support(build_virtual_profile_config_bundle( + &projection, + &state, + &fixture.manifest.workspace_id, + &now, + PROFILE, + ))? + .ok_or_else(|| failure("fixture Profile is not registered"))?; + let archive = bundle + .profile_source_archive + .as_ref() + .ok_or_else(|| failure("fixture Profile archive is absent"))?; + let mut resolved = support( + support(archive.verify())?.resolve_profile( + PROFILE, + &environment + .runtime_data_dir + .join("runtime/workers") + .join(&fixture.manifest.worker_id), + worker_name, + ), + )?; + if resolved.model.ref_.as_deref() != Some(fixture.manifest.model.as_str()) { + return Err(failure("resolved Profile changed the supplied model")); + } + let request = WorkerSpawnRequest { + intent: WorkerSpawnIntent::WorkspaceCoding, + requested_worker_name: Some("Restore E2E fixture".into()), + singleton_key: None, + acceptance: WorkerSpawnAcceptanceRequirement::SocketReady, + profile: ProfileSelector::Named(PROFILE.into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachment_requests: vec![], + resolved_workdir_attachment_requests: vec![], + resolved_workdir_attachments: vec![], + resolved_config_bundle: None, + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: vec![], + resolved_control_operation: None, + resolved_execution_grant: None, + }; + let reservation = support(seed_fixed_worker( + &store, + FixedWorkerSeed { + workspace_id: &fixture.manifest.workspace_id, + runtime_id: &fixture.manifest.runtime_id, + worker_id: support(fixture.manifest.worker_id.parse())?, + allocation_key: "restore-e2e-worker", + request: &request, + }, + ))?; + if resolved.feature.memory.profile.enabled { + support( + resolved + .feature + .memory + .bind_workspace_settings(reservation.memory_settings.clone()), + )?; + } + Ok(Bootstrap { + manifest: resolved, + memory_settings: reservation.memory_settings, + token, + workspace_public_key, + create_fingerprint: reservation.create_fingerprint, + archive: archive.reference.clone(), + config_bundle: ConfigBundleRef { + id: bundle.metadata.id, + digest: bundle.metadata.digest, + }, + }) +} diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index 933d7a696..bebae656f 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -1,4 +1,37 @@ [ + { + "path": "tests/e2e/fixtures/worker-codex-v1/manifest.json", + "kind": "saved_data", + "reason": "ユーザー提供済v1固定資産の生成元Git commitの保存key。原本の取得証拠とSHA-256を保持するため変更しない。Worker実行状態や競合制御の番号ではなく、生成元resources照合のみに使う。", + "lines": [ + { + "sha256": "fbf8f92576eb6afbb03502d2f95144651373ce8cd7b4401bc06f801fdb4db28d", + "count": 1 + } + ] + }, + { + "path": "tests/e2e/fixtures/worker-codex-v1/expected.json", + "kind": "saved_data", + "reason": "ユーザー提供済v1固定資産の生成元Git commitの保存key。原本の取得証拠とSHA-256を保持するため変更しない。Worker実行状態や競合制御の番号ではなく、生成元resources照合のみに使う。", + "lines": [ + { + "sha256": "fbf8f92576eb6afbb03502d2f95144651373ce8cd7b4401bc06f801fdb4db28d", + "count": 1 + } + ] + }, + { + "path": "tests/e2e/tests/worker_restore/fixture.rs", + "kind": "saved_data", + "reason": "提供済v1原本の生成元Git commitを読むための固定保存key対応。原本bytesとhashを変更しない。実行状態の観測tokenや更新番号に利用せず、現行Rust内ではsource_commitとして扱う。", + "lines": [ + { + "sha256": "4dd4810d1a31b812596b9d6cc8a8b27f398476174dfa6f0c3c1881aeadb5ade4", + "count": 2 + } + ] + }, { "path": "crates/subjektiv/src/journal/memory_links/tests.rs", "kind": "saved_data",