diff --git a/.config/nextest.toml b/.config/nextest.toml index 6bcd5db6..f7b9af8d 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -160,6 +160,7 @@ binary_id(cyber-account) | binary_id(cyber-server::worktree_setup_journal) | binary_id(cyber-server::worktree_setup_output) | binary_id(cyber-snapshot) +| binary_id(cyber-snapshot::file_copy_platform) | binary_id(cyber-store) & (test(writer::tests::) | test(migrate::tests::messaging_) | test(migrate::tests::usage_origin_)) | binary_id(cyber-store::store) | binary_id(cyber-tools) & (test(tools::bash::shell_tests) | test(plugin_host::) | test(browser::) | test(dev_server::) | test(permissions::engine::tests::message_send) | test(permissions::engine::tests::remote_attach) | test(remote_skills) | test(lsp::) | test(mcp::stdio::tests) | test(mcp::roots::tests) | test(mcp::elicitation::tests) | test(mcp::sampling::tests) | test(tools::process::tests) | test(tools::tool_search::tests) | test(mcp::pool::tests) | test(mcp::launch::tests) | test(mcp::http::tests) | test(mcp::oauth) | test(hook_commands::launch::tests) | test(sandboxing::tests) | test(permissions::filesystem) | test(tools::background::tests) | test(tools::monitor::) | test(tools::deliver::tests) | test(tools::powershell::tests) | test(permissions::dry_run) | test(sandboxing::tests::refusal_lines) | test(permissions::engine::tests::sandbox_boundary_needs_an_explicit_user_allow) | test(permissions::engine::tests::workflow_run) | test(permissions::taint::) | test(untrusted::)) diff --git a/.github/workflows/non-git-snapshot-proof.yml b/.github/workflows/non-git-snapshot-proof.yml new file mode 100644 index 00000000..d388f5dd --- /dev/null +++ b/.github/workflows/non-git-snapshot-proof.yml @@ -0,0 +1,46 @@ +name: Non-Git snapshot native proof +on: + pull_request: + paths: + - crates/cyber-snapshot/** + - crates/cyber-core/src/binary/native/** + - crates/cyber-core/src/binary/native.rs + - Cargo.lock + - .github/workflows/non-git-snapshot-proof.yml + workflow_dispatch: +permissions: + contents: read +concurrency: + group: non-git-snapshots-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + native: + name: File-copy snapshots (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, macos-15-intel] + env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: non-git-snapshot-proof + - name: Native storage, conflict and process-kill proof + shell: bash + run: | + set -euo pipefail + cargo test --locked -p cyber-snapshot -- --nocapture 2>&1 | tee non-git-snapshot-proof.log + - name: Retain native evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: non-git-snapshot-proof-${{ matrix.os }} + path: non-git-snapshot-proof.log + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index 03066932..6913fe6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,19 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -762,6 +775,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -1279,11 +1298,20 @@ dependencies = [ name = "cyber-snapshot" version = "0.1.0" dependencies = [ + "blake3", + "cap-fs-ext", + "cap-std", "cyber-core", "cyber-server", + "diffy", "futures", + "ignore", + "libc", + "rustix", + "serde", "serde_json", "sha1", + "similar 3.2.0", "tempfile", "tokio", "ulid", @@ -1553,6 +1581,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "diffy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b545b8c50194bdd008283985ab0b31dba153cfd5b3066a92770634fbc0d7d291" +dependencies = [ + "nu-ansi-term", +] + [[package]] name = "digest" version = "0.10.7" @@ -3216,6 +3253,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" diff --git a/README.md b/README.md index 1946a9dd..ced6f114 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The server owns execution and stores state in SQLite, so work can outlive a term For independent patch attempts, host-recorded tests and a separate read-only judge, start with the [verified `solve` workflow](docs/verified-workflows.md). Candidate patches remain available for human review; nothing is merged automatically. To combine complementary commits, use the [verified integration queue](docs/verified-integrations.md): submit a dependency manifest, test the combined patch, review independent advice, then explicitly publish a branch and PR. -[Reviewed rewind](docs/rewind.md) previews code, conversation or combined rewind with `cyber sessions rewind --to `. Applying requires the reviewed `--confirm `; changed context is refused, unrelated edits are preserved and later messages remain on an inactive branch. Code rewind currently requires Git snapshots. The TUI offers `/rewind`, idle Esc-Esc and reviewed `/undo`/`/redo` with guarded composer restoration. Non-Git snapshots and broader recovery acceptance remain in progress under [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). +[Reviewed rewind](docs/rewind.md) previews code, conversation or combined rewind with `cyber sessions rewind --to `. Applying requires the reviewed `--confirm `; changed context is refused, unrelated edits are preserved and later messages remain on an inactive branch. Code rewind uses Git snapshots, with a private file-copy fallback for admitted Linux/macOS non-Git Locations. The TUI offers `/rewind`, idle Esc-Esc and reviewed `/undo`/`/redo` with guarded composer restoration. File-copy restores retain recovery journals; managed recovery resolution and broader acceptance remain in progress under [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). Experimental [native computer use](docs/computer-use.md) adds macOS 14+ application/display screenshots and input with expiring owner grants, CLI/browser owner controls, private evidence and emergency Stop. Off by default; controlled macOS capture, input and Stop acceptance passed. Other layouts and signed distribution remain release checks. diff --git a/ROADMAP.md b/ROADMAP.md index 4e2ba282..1e225cec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ The [current inventory](docs/implementation/spec-doc-reconciliation.md) contains **Usage limit waits (`session-runtime`, `configuration`, `exec-mode`, `tui`, `web-client`, P1; `goals`, `loops-scheduling`, `workflows`, P2): Status: implemented** (change [`add-usage-limit-wait`](openspec/changes/add-usage-limit-wait/proposal.md)). An unattended run (`cyber exec`, a Goal, a loop iteration, a workflow agent, a teammate, or their subagents) that hits a subscription usage limit with a known reset waits for it and retries the Turn instead of failing: within `usage_limit.max_wait` (default 6 h), 10 s after the reset plus per-Session jitter, as one new attempt from the same history. The wait is durable (`session.usage_limit.waiting|resumed|abandoned.1`), cancelled by the normal interrupt, resumed after a restart for top-level Sessions and ended cleanly for owned ones; wall deadlines (Session, run, Goal and loop `max_wall_seconds`, `exec --timeout`) win over waiting. `exec` reports `usage_limit` stream-json lines, the TUI offers `/resume-at-reset` and a footer badge, and the dashboard shows the wait. The model fallback chain, once implemented, runs first. -**Daily coding (P1), in progress:** issue [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93) is the current priority. [Reviewed rewind](docs/rewind.md) provides typed CLI previews and explicit confirmation, fresh snapshot/Session revalidation and branch preservation, plus the TUI menu, idle Esc-Esc and staged `/undo`/`/redo` with guarded composer restoration. Broader rewind recovery acceptance, private non-Git snapshots, path rules/context, root structured output and JSONL input remain unfinished. The retry-count contract is reconciled without claiming structured-output implementation. +**Daily coding (P1), in progress:** issue [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93) is the current priority. [Reviewed rewind](docs/rewind.md) provides typed CLI previews and explicit confirmation, fresh snapshot/Session revalidation and branch preservation, plus the TUI menu, idle Esc-Esc and staged `/undo`/`/redo` with guarded composer restoration. A private BLAKE3 file-copy backend now supports admitted Linux/macOS non-Git Locations, with retained restore intents, preimages and read-only owner inspection. Managed recovery resolution, journal reclamation, complete Git historical exclusions, broader native acceptance, path rules/context, root structured output and JSONL input remain unfinished. The retry-count contract is reconciled without claiming structured-output implementation. **Binary hex editor (P1), in progress:** [`add-binary-hex-editor`](openspec/changes/add-binary-hex-editor/proposal.md) now provides the [live binary workspace](docs/binary-editor.md): paged hex/ASCII inspection, literal search, exact goto, bookmarks, LE/BE interpretation and scoped owner API/SDK access. The user prioritized issue #94 on 2026-11-01 ahead of the unfinished plugins/hooks and daily-coding work. The native core and owner API/SDK now provide durable staged patches, draft undo/redo, byte review and explicit recovery on admitted Linux/macOS workspaces. The terminal now stages edits with draft undo/redo and explicit reviewed save/Restore Last Save through the same owner API. Acknowledged saves feed the native FileChanged producer. Authorized Sessions can review the shared saved-version catalog and Session diffs include binary references. The model-facing `binary` tool supports bounded inspect/read/search and expected-digest patches through separate invocation drafts and the retained native save engine. Model-operation discovery/receipt reconciliation stays separate from editable manual handles. Checkpoint/retention/rewind adapters and remaining acceptance are still open; this is not full editor acceptance. diff --git a/crates/cyber-app/src/lib.rs b/crates/cyber-app/src/lib.rs index 54ff8af8..4a6acacd 100644 --- a/crates/cyber-app/src/lib.rs +++ b/crates/cyber-app/src/lib.rs @@ -323,9 +323,28 @@ impl App { }))?; let snapshots: Arc = if opts.snapshots { let loader = Arc::clone(&config); - let snaps = cyber_snapshot::GitSnapshots::new( + let snapshot_store = store.clone(); + let references: Arc = Arc::new(move || { + let rows = snapshot_store.read(|db| { + let mut query = db.prepare("SELECT DISTINCT j.value FROM event e,json_tree(e.data) j WHERE j.type='text' AND j.value LIKE 'copy:%' LIMIT 100001")?; + let rows = query.query_map([], |row|row.get::<_,String>(0))?.collect::,_>>()?; + Ok(rows) + }).map_err(|error|error.to_string())?; + if rows.len() > 100000 { + return Err("Snapshot reference limit exceeded; pruning refused".into()); + } + Ok(rows.into_iter().collect()) + }); + let snaps = cyber_snapshot::GitSnapshots::with_policy( opts.paths.data.clone(), Arc::new(move |p| loader(p).map(|(v, _)| v).unwrap_or(Value::Null)), + vec![ + opts.paths.config.clone(), + opts.paths.state.clone(), + opts.paths.cache.clone(), + opts.paths.tmp.clone(), + ], + Some(references), ); snaps.spawn_gc(); snaps diff --git a/crates/cyber-cli/src/commands/exec/mod.rs b/crates/cyber-cli/src/commands/exec/mod.rs index 5238706c..ba41aa8d 100644 --- a/crates/cyber-cli/src/commands/exec/mod.rs +++ b/crates/cyber-cli/src/commands/exec/mod.rs @@ -515,6 +515,16 @@ async fn handle( event.data["message"].as_str().unwrap_or_default() ); } + "session.error" if event.data["kind"] == "snapshot" => { + // Snapshot capture is optional; its warning cannot turn a completed + // conversation into a failed exec result. + eprintln!( + "snapshot warning: {}", + event.data["message"] + .as_str() + .unwrap_or("code rewind unavailable") + ); + } "session.error" => { if event.data["kind"] == "budget_exceeded" { run.stop_reason diff --git a/crates/cyber-cli/tests/branches_cli.rs b/crates/cyber-cli/tests/branches_cli.rs index b1d13169..1587e28c 100644 --- a/crates/cyber-cli/tests/branches_cli.rs +++ b/crates/cyber-cli/tests/branches_cli.rs @@ -459,6 +459,13 @@ fn conversation_rewind_works_without_git_and_stale_context_never_applies() { let model = scripted_model::ScriptedModel::start(reply); let env = Env::new(&model); std::fs::remove_dir_all(env.root.join("repo/.git")).unwrap(); + let mut config = model.config(); + config["snapshots"] = json!(false); + std::fs::write( + env.root.join("cyber-home/config/cyber.jsonc"), + config.to_string(), + ) + .unwrap(); let id = env.exec(None, "first prompt"); let first = env.user_ids(&id)[0].clone(); let refused = exits(&env.cyber(&["sessions", "rewind", &id, "--to", &first]), 2); @@ -724,3 +731,111 @@ fn interrupt_and_wait_idle(env: &Env, id: &str) { std::thread::sleep(Duration::from_millis(20)); } } + +#[test] +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn reviewed_non_git_code_rewind_uses_private_snapshots_and_keeps_owner_files() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + std::fs::remove_dir_all(env.root.join("repo/.git")).unwrap(); + let id = env.exec(None, "WRITE-A"); + let first = env.user_ids(&id)[0].clone(); + std::fs::write(env.root.join("repo/owner.txt"), "owner bytes\n").unwrap(); + let preview = rewind_preview(&env, &id, &first, "both"); + assert!( + preview["code"]["target"] + .as_str() + .unwrap() + .starts_with("copy:") + ); + let requests = model.requests().len(); + json(&confirm_rewind(&env, &id, &first, "both", &preview)); + let journals = + json(&env.cyber(&["api", "GET", &format!("/sessions/{id}/rewind/recovery")]))["data"] + .as_array() + .unwrap() + .clone(); + assert_eq!(journals.len(), 1); + assert_eq!(journals[0]["filesystem_complete"], true); + assert!( + journals[0]["paths"] + .as_array() + .unwrap() + .iter() + .any(|p| p == "a.txt") + ); + assert_eq!(env.file("a.txt"), None); + assert_eq!(env.file("owner.txt").as_deref(), Some("owner bytes\n")); + assert!(env.user_ids(&id).is_empty()); + assert_eq!(model.requests().len(), requests); + let all = json(&env.cyber(&["sessions", "export", &id, "--all-branches"])); + assert!(all.to_string().contains(&first)); +} + +#[test] +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn reviewed_non_git_cap_warns_once_without_failing_the_conversation() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + std::fs::remove_dir_all(env.root.join("repo/.git")).unwrap(); + let mut config = model.config(); + config["snapshots"] = json!({"fallback_max_bytes":1}); + std::fs::write( + env.root.join("cyber-home/config/cyber.jsonc"), + config.to_string(), + ) + .unwrap(); + let output = env.cyber(&["exec", "--format", "json", "first prompt"]); + exits(&output, 0); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(stderr.matches("snapshot warning:").count(), 1, "{stderr}"); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + let id = result["session_id"].as_str().unwrap(); + let first = env.user_ids(id)[0].clone(); + let refused = exits(&env.cyber(&["sessions", "rewind", id, "--to", &first]), 2); + assert!(refused.contains("cap reached"), "{refused}"); + let review = rewind_preview(&env, id, &first, "conversation"); + json(&confirm_rewind(&env, id, &first, "conversation", &review)); + assert!(env.user_ids(id).is_empty()); +} + +#[test] +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn non_git_recovery_inspection_creates_no_namespace_and_starts_no_inference() { + let model = scripted_model::ScriptedModel::start(reply); + let env = Env::new(&model); + std::fs::remove_dir_all(env.root.join("repo/.git")).unwrap(); + let session = json(&env.cyber(&[ + "api", + "POST", + "/sessions", + "-d", + &json!({"model":"test/main","mode":"accept-edits"}).to_string(), + ])); + let id = session["data"]["id"].as_str().unwrap(); + let result = json(&env.cyber(&["api", "GET", &format!("/sessions/{id}/rewind/recovery")])); + assert_eq!(result["data"], json!([])); + assert!(model.requests().is_empty()); + let registration: Value = serde_json::from_slice( + &std::fs::read(env.root.join("cyber-home/state/server.json")).unwrap(), + ) + .unwrap(); + let url = format!( + "{}/api/v1/sessions/{id}/rewind/recovery", + registration["url"].as_str().unwrap().trim_end_matches('/') + ); + let status = tokio::runtime::Runtime::new().unwrap().block_on(async { + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap() + .get(url) + .send() + .await + .unwrap() + .status() + }); + assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + assert!(!env.root.join("cyber-home/data/snapshot/global").exists()); +} diff --git a/crates/cyber-core/src/binary/native.rs b/crates/cyber-core/src/binary/native.rs index 1b058c95..99512476 100644 --- a/crates/cyber-core/src/binary/native.rs +++ b/crates/cyber-core/src/binary/native.rs @@ -95,6 +95,32 @@ pub(super) fn private_acl(file: &File) -> Result<(), Error> { Ok(()) } +/// Check that a mode-private directory has no macOS ACL grants bypassing its mode. +#[cfg(target_os = "macos")] +pub fn validate_private_directory_acl(file: &File) -> Result<(), Error> { + if !file.metadata().map_err(|_| Error::Io)?.is_dir() { + return Err(Error::File); + } + private_acl(file) +} + +/// Metadata a snapshot can recreate from ordinary ownership/mode fields alone. +/// ACLs, extended attributes and platform flags require separate retained metadata. +pub fn plain_snapshot_metadata(file: &File) -> Result { + let value = metadata(file)?; + if !attributes(file)?.is_empty() { + return Err(Error::File); + } + #[cfg(target_os = "macos")] + { + private_acl(file)?; + if file.metadata().map_err(|_| Error::Io)?.st_flags() != 0 { + return Err(Error::File); + } + } + Ok(value) +} + pub fn metadata(file: &File) -> Result { let m = file.metadata().map_err(|_| Error::Io)?; if !m.is_file() || m.nlink() != 1 || m.mode() & 0o7000 != 0 || m.permissions().readonly() { diff --git a/crates/cyber-server/src/http/openapi.rs b/crates/cyber-server/src/http/openapi.rs index be925026..4315da2e 100644 --- a/crates/cyber-server/src/http/openapi.rs +++ b/crates/cyber-server/src/http/openapi.rs @@ -758,6 +758,8 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec { ) .body::(g) .ok::>(g), + op("get", "/sessions/{sessionID}/rewind/recovery", "v1.session.snapshotRecovery", "Inspect retained filesystem restore journals without acknowledging or replaying native effects") + .ok::>>(g), op("post", "/sessions/{sessionID}/rewind/preview", "v1.session.rewindPreview", "Review a rewind without changing files or the active conversation") .body::(g) .ok::>(g), diff --git a/crates/cyber-server/src/http/sessions.rs b/crates/cyber-server/src/http/sessions.rs index 50bd133f..20c20fb0 100644 --- a/crates/cyber-server/src/http/sessions.rs +++ b/crates/cyber-server/src/http/sessions.rs @@ -345,6 +345,7 @@ pub fn routes() -> Router { .route(&format!("{s}/recap"), get(recap).post(generate_recap)) .route(&format!("{s}/rewind"), post(rewind)) .route(&format!("{s}/rewind/preview"), post(rewind_preview)) + .route(&format!("{s}/rewind/recovery"), get(snapshot_recovery)) .route(&format!("{s}/rewind/confirm"), post(rewind_confirm)) .route(&format!("{s}/revert/stage"), post(stage)) .route(&format!("{s}/revert/clear"), post(clear)) @@ -1188,6 +1189,14 @@ async fn answer_question( Ok(StatusCode::NO_CONTENT) } +async fn snapshot_recovery( + State(state): State, + Path(id): Path, +) -> Result>>> { + Ok(Json(Data { + data: state.runtime.snapshot_recovery(&id).await?, + })) +} async fn rewind_preview( State(state): State, Path(id): Path, diff --git a/crates/cyber-server/src/runtime/host.rs b/crates/cyber-server/src/runtime/host.rs index 96648399..e7aaa4fb 100644 --- a/crates/cyber-server/src/runtime/host.rs +++ b/crates/cyber-server/src/runtime/host.rs @@ -1410,8 +1410,26 @@ pub enum RestoreError { Failed(String), } +/// Retained filesystem evidence; completion does not acknowledge a Session/native receipt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SnapshotRecovery { + pub id: String, + /// The filesystem worker flushed its final record; not Session/native receipt acknowledgement. + pub filesystem_complete: bool, + pub paths: Vec, + /// Durably observed file checkpoints; absence does not prove that no effect occurred. + pub applied: Vec, +} + /// Working-tree snapshots for a Location. pub trait Snapshots: Send + Sync { + /// Actionable unavailability retained after a one-time automatic warning. + fn unavailable_reason(&self, _directory: &str) -> Option { + None + } + fn recovery(&self, _directory: &str) -> BoxFuture<'_, Result, String>> { + Box::pin(async { Err("Snapshot recovery inspection is unavailable".into()) }) + } /// Record the current tree; `None` when snapshots are off or unsupported here. fn track(&self, directory: &str) -> BoxFuture<'_, Result, String>>; @@ -1430,6 +1448,13 @@ pub trait Snapshots: Send + Sync { to: &str, ) -> BoxFuture<'_, Result, String>>; + fn exclusions( + &self, + _directory: &str, + _trees: Vec, + ) -> BoxFuture<'_, Result, String>> { + Box::pin(async { Ok(vec![]) }) + } fn reviewed_restore_available(&self) -> bool { false } diff --git a/crates/cyber-server/src/runtime/mod.rs b/crates/cyber-server/src/runtime/mod.rs index 1ae0e4fc..365e710d 100644 --- a/crates/cyber-server/src/runtime/mod.rs +++ b/crates/cyber-server/src/runtime/mod.rs @@ -221,9 +221,9 @@ pub use host::{ FileDiff, FileReadError, GoalWorktree, HookEffects, Invocation, LifecycleHook, LifecycleVerdict, LocationGuard, LocationLease, ModelResolver, NoSnapshots, NoTools, Provenance, Reconciliation, ResolvedModel, RestoreError, RuleEffect, RunWorktree, - SessionEnvelope, SkillSuggestion, Snapshot, Snapshots, TeamWorktree, ToolDef, ToolHost, - ToolOutcome, ToolScope, TurnContext, TurnWorktree, WorkflowAgentOutput, WorkflowAgentRequest, - WorkflowToolRequest, + SessionEnvelope, SkillSuggestion, Snapshot, SnapshotRecovery, Snapshots, TeamWorktree, ToolDef, + ToolHost, ToolOutcome, ToolScope, TurnContext, TurnWorktree, WorkflowAgentOutput, + WorkflowAgentRequest, WorkflowToolRequest, }; pub use model::{ AssistantEntry, CallState, CallStatus, Delivery, Entry, InboxRow, InputStatus, LastStep, diff --git a/crates/cyber-server/src/runtime/rewind_preview.rs b/crates/cyber-server/src/runtime/rewind_preview.rs index ef91958c..6fabfb3f 100644 --- a/crates/cyber-server/src/runtime/rewind_preview.rs +++ b/crates/cyber-server/src/runtime/rewind_preview.rs @@ -208,10 +208,8 @@ impl Inner { selection: &RewindSelection, ) -> Result { let unavailable = || { - RuntimeError::Invalid( - "Snapshots are unavailable for this Location; only the conversation can be rewound" - .into(), - ) + RuntimeError::Invalid(self.options.snapshots.unavailable_reason(&state.info.directory).unwrap_or_else(|| + "Snapshots are unavailable for this Location; only the conversation can be rewound".into())) }; if !self.options.snapshots.reviewed_restore_available() || (selection.action != RewindAction::Commit @@ -281,17 +279,56 @@ impl Inner { return Err(RuntimeError::Invalid(message)); } }; + let mut skipped = current.skipped; + skipped.extend( + self.options + .snapshots + .exclusions( + &state.info.directory, + vec![target.clone(), recorded.clone()], + ) + .await + .map_err(RuntimeError::Invalid)?, + ); + skipped.sort(); + skipped.dedup(); Ok(RewindCodePreview { baseline: current.tree, target, recorded, files, - skipped: current.skipped, + skipped, }) } } impl Runtime { + /// Read-only owner inspection; never settles evidence, restores files or starts work. + pub async fn snapshot_recovery( + &self, + session_id: &str, + ) -> Result, RuntimeError> { + use rusqlite::OptionalExtension; + let id = session_id.to_owned(); + // Inspection must remain available while restore holds the live projection. + let directory = self + .inner + .store + .read(move |db| { + Ok(db + .query_row("SELECT directory FROM session WHERE id=?1", [id], |row| { + row.get::<_, String>(0) + }) + .optional()?) + })? + .ok_or_else(|| RuntimeError::SessionNotFound(session_id.into()))?; + self.inner + .options + .snapshots + .recovery(&directory) + .await + .map_err(RuntimeError::Invalid) + } /// Build a review without restoring files, staging a revert, or changing the branch. pub async fn rewind_preview( &self, diff --git a/crates/cyber-snapshot/Cargo.toml b/crates/cyber-snapshot/Cargo.toml index beff00fc..64203cae 100644 --- a/crates/cyber-snapshot/Cargo.toml +++ b/crates/cyber-snapshot/Cargo.toml @@ -13,9 +13,21 @@ cyber-server = { path = "../cyber-server" } futures = "0.3" serde_json.workspace = true sha1 = "0.10" +serde.workspace = true +blake3 = "1" +diffy = "0.4" +similar = "3.2" +ignore = "0.4" +cap-std = "4.0" +cap-fs-ext = "4.0" + tokio = { version = "1", features = ["rt", "process", "time", "sync", "fs", "macros", "io-util"] } ulid.workspace = true +[target.'cfg(unix)'.dependencies] +libc.workspace = true +rustix = { version = "=1.1.5", default-features = false, features = ["fs", "std"] } + [dev-dependencies] tempfile.workspace = true serde_json.workspace = true diff --git a/crates/cyber-snapshot/src/copy/mod.rs b/crates/cyber-snapshot/src/copy/mod.rs new file mode 100644 index 00000000..9f57ec7c --- /dev/null +++ b/crates/cyber-snapshot/src/copy/mod.rs @@ -0,0 +1,523 @@ +//! Private BLAKE3 snapshots for exact non-Git Locations; no user repository required. +mod restore; +mod storage; +mod tree; +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; +pub use cyber_server::runtime::SnapshotRecovery as Recovery; +use cyber_server::runtime::{FileDiff, Snapshot}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use storage::{Result, fail}; + +const RECORD_LIMIT: u64 = 8 * 1024 * 1024; +const ENTRY_LIMIT: usize = 4096; +#[derive(Clone)] +pub(crate) struct Options { + pub max_file: u64, + pub max_total: u64, + pub ignore: Vec, + pub private_roots: Vec, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + object: String, + length: u64, + mode: u32, + uid: u32, + gid: u32, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Tree { + version: u32, + location: PathBuf, + root: String, + files: BTreeMap, + skipped: Vec, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + accessed: u64, + tree: Tree, +} +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(crate) struct Store { + root: PathBuf, + root_id: String, + data_path: PathBuf, + work: Dir, + + objects: Dir, + manifests: Dir, + journals: Dir, + _lock: Option, + options: Options, + usage: std::cell::Cell>, +} +impl Store { + pub fn open(data: &Path, location: &str, options: Options) -> Result { + Self::open_mode(data, location, options, true) + } + pub fn existing(data: &Path, location: &str, options: Options) -> Result> { + if !data.exists() { + return Ok(None); + } + let root = Path::new(location).canonicalize().map_err(fail)?; + let work = Dir::open_ambient_dir(&root, cap_std::ambient_authority()).map_err(fail)?; + let id = storage::dir_identity(&work)?; + let key = storage::digest(format!("{}\0{}", root.display(), id).as_bytes()); + let data_dir = Dir::open_ambient_dir(data, cap_std::ambient_authority()).map_err(fail)?; + if data_dir.symlink_metadata("snapshot").is_err() { + return Ok(None); + } + let snapshot = storage::existing_child(&data_dir, "snapshot", false)?; + if snapshot.symlink_metadata("global").is_err() { + return Ok(None); + } + let global = storage::existing_child(&snapshot, "global", true)?; + if global.symlink_metadata(&key).is_err() { + return Ok(None); + } + Self::open_mode(data, location, options, false).map(Some) + } + fn open_mode(data: &Path, location: &str, mut options: Options, create: bool) -> Result { + options.private_roots = options + .private_roots + .into_iter() + .map(|p| p.canonicalize().unwrap_or(p)) + .collect(); + let root = Path::new(location).canonicalize().map_err(fail)?; + if !root.is_dir() || root.parent().is_none() { + return Err("Snapshot Location must be a project directory".into()); + } + if create { + std::fs::create_dir_all(data).map_err(fail)?; + } + let data_path = data.canonicalize().map_err(fail)?; + if root.starts_with(&data_path) { + return Err("Private owner state cannot be a snapshot Location".into()); + } + let work = Dir::open_ambient_dir(&root, cap_std::ambient_authority()).map_err(fail)?; + let root_id = storage::dir_identity(&work)?; + if options.private_roots.iter().any(|p| root.starts_with(p)) { + return Err("Private owner state cannot be a snapshot Location".into()); + } + let data = Dir::open_ambient_dir(&data_path, cap_std::ambient_authority()).map_err(fail)?; + use std::os::unix::fs::MetadataExt; + let data_metadata = data + .try_clone() + .map_err(fail)? + .into_std_file() + .metadata() + .map_err(fail)?; + if data_metadata.mode() & 0o022 != 0 { + return Err("Snapshot data directory is writable by other users".into()); + } + let child = |parent: &Dir, name: &str, private: bool| { + if !create { + storage::existing_child(parent, name, private) + } else if private { + storage::private_child(parent, name) + } else { + storage::shared_child(parent, name) + } + }; + let snapshot = child(&data, "snapshot", false)?; + let copy = child(&snapshot, "global", true)?; + let key = storage::digest(format!("{}\0{}", root.display(), root_id).as_bytes()); + let dir = child(©, &key, true)?; + let lock = if create { + Some(storage::lock(&dir, true)?) + } else { + None + }; + let objects = child(&dir, "files", true)?; + let manifests = child(&dir, "manifests", true)?; + let journals = child(&dir, "journals", true)?; + Ok(Self { + root, + root_id, + data_path, + work, + objects, + manifests, + journals, + _lock: lock, + options, + usage: std::cell::Cell::new(None), + }) + } + fn verify_root(&self) -> Result<()> { + let fresh = + Dir::open_ambient_dir(&self.root, cap_std::ambient_authority()).map_err(fail)?; + if storage::dir_identity(&fresh)? != self.root_id { + return Err("Snapshot Location identity changed".into()); + } + Ok(()) + } + fn usage(&self) -> Result { + if let Some(size) = self.usage.get() { + return Ok(size); + } + let mut size = 0u64; + for dir in [&self.objects, &self.manifests] { + for name in storage::names(dir, 100_000)? { + size = size + .checked_add( + storage::open(dir, Path::new(&name))? + .metadata() + .map_err(fail)? + .len(), + ) + .ok_or("Snapshot storage overflow")?; + } + } + // Journals include displaced preimages/staging files and are never quota-evicted. + for name in storage::names(&self.journals, ENTRY_LIMIT)? { + let journal = self.journals.open_dir_nofollow(&name).map_err(fail)?; + for file in storage::names(&journal, ENTRY_LIMIT * 3)? { + size = size + .checked_add( + storage::open(&journal, Path::new(&file))? + .metadata() + .map_err(fail)? + .len(), + ) + .ok_or("Snapshot storage overflow")?; + } + } + self.usage.set(Some(size)); + Ok(size) + } + fn reserve(&self, size: u64) -> Result<()> { + if self.options.max_total == 0 + || self + .usage()? + .checked_add(size) + .is_none_or(|n| n > self.options.max_total) + { + return Err( + "Non-Git snapshot storage cap reached; conversation rewind remains available" + .into(), + ); + } + self.usage.set(Some( + self.usage()? + .checked_add(size) + .ok_or("Snapshot storage overflow")?, + )); + Ok(()) + } + fn object(&self, bytes: &[u8]) -> Result { + let object = storage::digest(bytes); + match storage::read(&self.objects, &object, self.options.max_total) { + Ok(existing) if existing == bytes => {} + Ok(_) => return Err("Snapshot object identity mismatch".into()), + Err(_) if self.objects.symlink_metadata(&object).is_ok() => { + return Err("Unreadable snapshot object".into()); + } + Err(_) => { + self.reserve(bytes.len() as u64)?; + storage::atomic(&self.objects, &object, bytes)?; + } + } + Ok(Entry { + object, + length: bytes.len() as u64, + mode: 0o644, + uid: 0, + gid: 0, + }) + } + fn bytes(&self, entry: &Entry) -> Result> { + if !storage::valid_digest(&entry.object) || entry.length > self.options.max_total { + return Err("Invalid snapshot object".into()); + } + let bytes = storage::read(&self.objects, &entry.object, entry.length)?; + if bytes.len() as u64 != entry.length || storage::digest(&bytes) != entry.object { + return Err("Corrupt snapshot object".into()); + } + Ok(bytes) + } + fn save(&self, tree: Tree) -> Result { + let digest = storage::digest(&serde_json::to_vec(&tree).map_err(fail)?); + let bytes = serde_json::to_vec(&Manifest { + accessed: now(), + tree: tree.clone(), + }) + .map_err(fail)?; + if bytes.len() as u64 > RECORD_LIMIT { + return Err("Snapshot manifest exceeds limit".into()); + } + let old = self + .manifests + .symlink_metadata(&digest) + .ok() + .map_or(0, |m| m.len()); + if bytes.len() as u64 > old { + self.reserve(bytes.len() as u64 - old)?; + } + storage::atomic(&self.manifests, &digest, &bytes)?; + Ok(Snapshot { + tree: format!("copy:{digest}"), + skipped: tree.skipped, + }) + } + fn load(&self, id: &str) -> Result { + self.verify_root()?; + let digest = id + .strip_prefix("copy:") + .filter(|s| storage::valid_digest(s)) + .ok_or("Invalid non-Git snapshot identity")?; + let manifest: Manifest = + serde_json::from_slice(&storage::read(&self.manifests, digest, RECORD_LIMIT)?) + .map_err(fail)?; + let tree = manifest.tree; + if tree.version != 1 + || tree.location != self.root + || tree.root != self.root_id + || tree.files.len() > ENTRY_LIMIT + || storage::digest(&serde_json::to_vec(&tree).map_err(fail)?) != digest + { + return Err("Snapshot belongs to a different Location or is corrupt".into()); + } + // A preview/recovery lease keeps an otherwise unreferenced manifest recent. + self.save(tree.clone())?; + Ok(tree) + } + pub fn track(&self) -> Result { + self.ensure_recovered()?; + self.save(self.capture()?) + } + pub fn changed(&self, from: &str, to: &str) -> Result> { + let (from, to) = (self.load(from)?, self.load(to)?); + Ok(changes(&from, &to)) + } + pub fn diff(&self, from: &str, to: &str) -> Result> { + let (from, to) = (self.load(from)?, self.load(to)?); + changes(&from, &to) + .into_iter() + .map(|path| self.file_diff(&path, from.files.get(&path), to.files.get(&path))) + .collect() + } + fn file_diff( + &self, + path: &str, + before: Option<&Entry>, + after: Option<&Entry>, + ) -> Result { + let a = before + .map(|e| self.bytes(e)) + .transpose()? + .unwrap_or_default(); + let b = after + .map(|e| self.bytes(e)) + .transpose()? + .unwrap_or_default(); + let (mut patch, additions, deletions) = + match (std::str::from_utf8(&a), std::str::from_utf8(&b)) { + (Ok(a), Ok(b)) if !a.contains('\0') && !b.contains('\0') => { + let diff = similar::TextDiff::from_lines(a, b); + let additions = diff + .iter_all_changes() + .filter(|c| c.tag() == similar::ChangeTag::Insert) + .count() as u32; + let deletions = diff + .iter_all_changes() + .filter(|c| c.tag() == similar::ChangeTag::Delete) + .count() as u32; + ( + diff.unified_diff() + .header(&format!("a/{path}"), &format!("b/{path}")) + .to_string(), + additions, + deletions, + ) + } + _ => ("Binary files differ\n".into(), 0, 0), + }; + if let (Some(a), Some(b)) = (before, after) + && a.mode != b.mode + { + patch = format!("old mode {:04o}\nnew mode {:04o}\n{patch}", a.mode, b.mode); + } + Ok(FileDiff { + file: path.into(), + status: match (before, after) { + (None, _) => "added", + (_, None) => "deleted", + _ => "modified", + } + .into(), + patch, + additions, + deletions, + binary: None, + }) + } +} +fn changes(a: &Tree, b: &Tree) -> Vec { + a.files + .keys() + .chain(b.files.keys()) + .collect::>() + .into_iter() + .filter(|p| a.files.get(*p) != b.files.get(*p) && !excluded(a, p) && !excluded(b, p)) + .cloned() + .collect() +} + +/// Prune only unreferenced, old manifests. Journals keep all their objects pinned; +/// an unavailable durable-reference view never authorizes deletion. +pub(crate) fn gc( + data: &Path, + config: &std::sync::Arc, + protected: &BTreeSet, +) -> Result<()> { + if !data.exists() { + return Ok(()); + } + let data = Dir::open_ambient_dir(data, cap_std::ambient_authority()).map_err(fail)?; + if data.symlink_metadata("snapshot").is_err() { + return Ok(()); + } + let snapshot = storage::existing_child(&data, "snapshot", false)?; + if snapshot.symlink_metadata("global").is_err() { + return Ok(()); + } + let root = storage::existing_child(&snapshot, "global", true)?; + for location in storage::names(&root, 100_000)? { + if !storage::valid_digest(&location) { + return Err("Invalid snapshot namespace".into()); + } + let dir = storage::private_child(&root, &location)?; + let _lock = match storage::lock(&dir, false) { + Ok(lock) => lock, + Err(_) => continue, + }; + gc_location(&dir, config, protected)?; + } + Ok(()) +} +fn gc_location( + dir: &Dir, + config: &std::sync::Arc, + protected: &BTreeSet, +) -> Result<()> { + let manifests = storage::private_child(dir, "manifests")?; + let journals = storage::private_child(dir, "journals")?; + // Until an owner resolves/acknowledges native evidence, no journal is evicted. + if !storage::names(&journals, ENTRY_LIMIT)?.is_empty() { + return Ok(()); + } + let objects = storage::private_child(dir, "files")?; + let mut keep = BTreeSet::new(); + for name in storage::names(&manifests, 100_000)? { + if temporary(&name) { + prune_temporary(&manifests, &name)?; + continue; + } + if !storage::valid_digest(&name) { + return Err("Unrecognized snapshot manifest".into()); + } + let manifest: Manifest = + serde_json::from_slice(&storage::read(&manifests, &name, RECORD_LIMIT)?) + .map_err(fail)?; + if manifest.tree.version != 1 + || storage::digest(&serde_json::to_vec(&manifest.tree).map_err(fail)?) != name + { + return Err("Corrupt snapshot manifest".into()); + } + let days = config(&manifest.tree.location) + .pointer("/snapshots/retention_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(7); + let old = days != 0 && now().saturating_sub(manifest.accessed) > days.saturating_mul(86400); + if old && !protected.contains(&format!("copy:{name}")) { + manifests.remove_file(&name).map_err(fail)?; + } else { + keep.extend( + manifest + .tree + .files + .values() + .map(|entry| entry.object.clone()), + ); + } + } + storage::sync(&manifests)?; + for name in storage::names(&objects, 100_000)? { + if temporary(&name) { + prune_temporary(&objects, &name)?; + continue; + } + if !storage::valid_digest(&name) { + continue; + } + if !keep.contains(&name) { + let metadata = storage::open(&objects, Path::new(&name))? + .metadata() + .map_err(fail)?; + let old = metadata + .modified() + .ok() + .and_then(|t| std::time::SystemTime::now().duration_since(t).ok()) + .is_some_and(|age| age.as_secs() > 7 * 86400); + if old { + objects.remove_file(&name).map_err(fail)?; + } + } + } + storage::sync(&objects) +} + +fn excluded(tree: &Tree, path: &str) -> bool { + tree.skipped + .iter() + .filter_map(|p| p.split_once(": ").map(|(_, p)| p)) + .any(|p| path == p || path.starts_with(&format!("{p}/"))) +} +impl Store { + pub fn exclusions(&self, ids: &[String]) -> Result> { + let mut skipped = BTreeSet::new(); + for id in ids { + let tree = self.load(id)?; + skipped.extend( + tree.skipped + .into_iter() + .map(|path| format!("historical {id}: {path}")), + ); + } + Ok(skipped.into_iter().collect()) + } +} + +fn temporary(name: &str) -> bool { + name.strip_prefix("tmp_") + .is_some_and(|id| id.len() == 26 && id.parse::().is_ok()) +} +fn prune_temporary(dir: &Dir, name: &str) -> Result<()> { + let file = storage::open(dir, Path::new(name))?; + let old = file + .metadata() + .map_err(fail)? + .modified() + .ok() + .and_then(|t| std::time::SystemTime::now().duration_since(t).ok()) + .is_some_and(|age| age.as_secs() > 7 * 86400); + if old { + dir.remove_file(name).map_err(fail)?; + storage::sync(dir)?; + } + Ok(()) +} diff --git a/crates/cyber-snapshot/src/copy/restore.rs b/crates/cyber-snapshot/src/copy/restore.rs new file mode 100644 index 00000000..24904774 --- /dev/null +++ b/crates/cyber-snapshot/src/copy/restore.rs @@ -0,0 +1,359 @@ +//! A durable intent precedes every namespace mutation; incomplete intents fence reuse. +use super::storage::{self, Result, fail}; +use super::{ENTRY_LIMIT, Entry, RECORD_LIMIT, Store, Tree, now}; +use cap_fs_ext::DirExt; +use cyber_server::runtime::{FileDiff, RestoreError}; +use serde::{Deserialize, Serialize}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Change { + path: String, + before: Option, + after: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + version: u32, + location: std::path::PathBuf, + root: String, + created: u64, + completed: bool, + changes: Vec, + applied: Vec, +} + +impl Store { + pub(super) fn ensure_recovered(&self) -> Result<()> { + if self.recovery()?.iter().any(|r| !r.filesystem_complete) { + return Err( + "Snapshot restore requires recovery; private journal and preimages retained".into(), + ); + } + Ok(()) + } + pub fn recovery(&self) -> Result> { + let mut result = vec![]; + let mut response_bytes = 32usize; + for id in storage::names(&self.journals, ENTRY_LIMIT)? { + let dir = self.journals.open_dir_nofollow(&id).map_err(fail)?; + let record: Journal = + serde_json::from_slice(&storage::read(&dir, "intent.json", RECORD_LIMIT)?) + .map_err(fail)?; + if record.version != 1 + || record.location != self.root + || record.root != self.root_id + || record.changes.len() > ENTRY_LIMIT + { + return Err("Invalid snapshot recovery journal".into()); + } + let view = super::Recovery { + id, + filesystem_complete: record.completed, + paths: record.changes.iter().map(|c| c.path.clone()).collect(), + applied: record.applied, + }; + response_bytes = response_bytes + .saturating_add(serde_json::to_vec(&view).map_err(fail)?.len()) + .saturating_add(1); + if response_bytes > 4 * 1024 * 1024 { + return Err("Snapshot recovery catalog exceeds response limit".into()); + } + result.push(view); + } + Ok(result) + } + fn plan( + &self, + target: &str, + recorded: &str, + expected: &str, + paths: Option<&[String]>, + ) -> std::result::Result<(Tree, Vec), RestoreError> { + self.ensure_recovered().map_err(RestoreError::Failed)?; + let before = self.capture().map_err(RestoreError::Failed)?; + if self + .save(before.clone()) + .map_err(RestoreError::Failed)? + .tree + != expected + { + return Err(RestoreError::Stale); + } + let target = self.load(target).map_err(RestoreError::Failed)?; + let recorded = self.load(recorded).map_err(RestoreError::Failed)?; + let mut changes = vec![]; + let mut conflicts = vec![]; + for path in super::changes(&recorded, &target) { + if paths.is_some_and(|allowed| !allowed.contains(&path)) { + continue; + } + if before + .skipped + .iter() + .filter_map(|p| p.split_once(": ").map(|(_, p)| p)) + .any(|p| path == p || path.starts_with(&format!("{p}/"))) + { + continue; + } + let current = self.current(&path).map_err(RestoreError::Failed)?; + let base = recorded.files.get(&path); + let want = target.files.get(&path); + let mut next = if current.as_ref() == want { + continue; + } else if current.as_ref() == base { + want.cloned() + } else { + match self + .merged(current.as_ref(), base, want) + .map_err(RestoreError::Failed)? + { + Some(entry) => Some(entry), + None => { + conflicts.push(path); + continue; + } + } + }; + // Existing ownership is owner state, not an agent edit to undo. + if let (Some(current), Some(next)) = (current.as_ref(), next.as_mut()) { + next.uid = current.uid; + next.gid = current.gid; + } + if next == current { + continue; + } + if current.is_some() { + let native = + cyber_core::binary::native::Target::capture(&self.root, Path::new(&path)) + .map_err(|e| { + RestoreError::Failed(format!( + "Restore metadata unsupported for {path}: {e}" + )) + })?; + native.same_filesystem(&self.objects).map_err(|e| { + RestoreError::Failed(format!( + "Restore requires same-filesystem private storage: {e}" + )) + })?; + } + changes.push(Change { + path, + before: current, + after: next, + }); + } + if !conflicts.is_empty() { + return Err(RestoreError::Conflict(conflicts)); + } + Ok((before, changes)) + } + fn merged( + &self, + current: Option<&Entry>, + base: Option<&Entry>, + want: Option<&Entry>, + ) -> Result> { + let (Some(current), Some(base), Some(want)) = (current, base, want) else { + return Ok(None); + }; + let (ours, base, theirs) = (self.bytes(current)?, self.bytes(base)?, self.bytes(want)?); + let (Ok(ours), Ok(base), Ok(theirs)) = ( + std::str::from_utf8(&ours), + std::str::from_utf8(&base), + std::str::from_utf8(&theirs), + ) else { + return Ok(None); + }; + if [ours, base, theirs].iter().any(|s| s.contains('\0')) { + return Ok(None); + } + let Ok(merged) = diffy::merge(base, ours, theirs) else { + return Ok(None); + }; + if merged.len() as u64 > self.options.max_file { + return Err("Merged snapshot exceeds file limit".into()); + } + let mut entry = self.object(merged.as_bytes())?; + entry.mode = current.mode; + entry.uid = current.uid; + entry.gid = current.gid; + Ok(Some(entry)) + } + pub fn preview( + &self, + target: &str, + recorded: &str, + expected: &str, + paths: Option<&[String]>, + ) -> std::result::Result, RestoreError> { + let (_, changes) = self.plan(target, recorded, expected, paths)?; + changes + .iter() + .map(|c| { + self.file_diff(&c.path, c.before.as_ref(), c.after.as_ref()) + .map_err(RestoreError::Failed) + }) + .collect() + } + pub fn restore( + &self, + target: &str, + recorded: &str, + expected: &str, + paths: Option<&[String]>, + authorize: &(dyn Fn() -> Result<()> + Send + Sync), + ) -> std::result::Result, RestoreError> { + authorize().map_err(RestoreError::Failed)?; + let (_, changes) = self.plan(target, recorded, expected, paths)?; + if changes.is_empty() { + return Ok(vec![]); + } + let record_bytes = serde_json::to_vec(&changes) + .map_err(|e| RestoreError::Failed(e.to_string()))? + .len() as u64; + let reserve = changes + .iter() + .map(|c| { + c.before + .as_ref() + .map_or(0, |e| e.length) + .max(c.after.as_ref().map_or(0, |e| e.length)) + }) + .try_fold( + record_bytes.saturating_mul(2).saturating_add(4096), + |sum, length| sum.checked_add(length).ok_or("Restore size overflow"), + ) + .map_err(|e| RestoreError::Failed(e.into()))?; + self.reserve(reserve).map_err(RestoreError::Failed)?; + // The directory is unique. A crash before publishing its intent is also + // unreadable recovery evidence and fails closed on the next open. + let id = format!("restore_{}", ulid::Ulid::new()); + let dir = storage::private_child(&self.journals, &id).map_err(RestoreError::Failed)?; + let mut journal = Journal { + version: 1, + location: self.root.clone(), + root: self.root_id.clone(), + created: now(), + completed: false, + changes, + applied: vec![], + }; + + Self::record(&dir, &journal).map_err(RestoreError::Failed)?; + for (i, change) in journal.changes.iter().enumerate() { + if let Some(after) = &change.after { + let file = storage::create( + &dir, + &format!("stage_{i}"), + &self.bytes(after).map_err(RestoreError::Failed)?, + ) + .map_err(RestoreError::Failed)?; + Self::stage_metadata(&file, after).map_err(RestoreError::Failed)?; + } + } + storage::sync(&dir).map_err(RestoreError::Failed)?; + for i in 0..journal.changes.len() { + authorize().map_err(RestoreError::Failed)?; + self.install(&dir, i, &journal.changes[i]) + .map_err(RestoreError::Failed)?; + journal.applied.push(journal.changes[i].path.clone()); + Self::record(&dir, &journal).map_err(RestoreError::Failed)?; + } + self.verify_root().map_err(RestoreError::Failed)?; + journal.completed = true; + Self::record(&dir, &journal).map_err(RestoreError::Failed)?; + Ok(journal.applied) + } + fn stage_metadata(file: &std::fs::File, entry: &Entry) -> Result<()> { + rustix::fs::fchown( + file, + Some(rustix::fs::Uid::from_raw(entry.uid)), + Some(rustix::fs::Gid::from_raw(entry.gid)), + ) + .map_err(fail)?; + file.set_permissions(std::fs::Permissions::from_mode(entry.mode)) + .map_err(fail)?; + file.sync_all().map_err(fail) + } + fn record(dir: &cap_std::fs::Dir, journal: &Journal) -> Result<()> { + let bytes = serde_json::to_vec(journal).map_err(fail)?; + if bytes.len() as u64 > RECORD_LIMIT { + return Err("Restore journal exceeds limit".into()); + } + storage::atomic(dir, "intent.json", &bytes) + } + fn install(&self, dir: &cap_std::fs::Dir, index: usize, change: &Change) -> Result<()> { + if self.current(&change.path)? != change.before { + return Err(format!( + "Restore source changed; journal retained: {}", + change.path + )); + } + let parent = self.parent(&change.path, true)?; + let parent_id = storage::dir_identity(&parent)?; + let leaf = Path::new(&change.path) + .file_name() + .ok_or("Invalid restore leaf")?; + let stage = format!("stage_{index}"); + match (&change.before, &change.after) { + (Some(before), Some(after)) => { + let target = cyber_core::binary::native::Target::capture( + &self.root, + Path::new(&change.path), + ) + .map_err(fail)?; + if target.length.0 != before.length + || target.metadata.mode != before.mode + || target.metadata.uid != before.uid + || target.metadata.gid != before.gid + { + return Err("Restore source metadata changed; journal retained".into()); + } + let (_, original) = target.validate().map_err(fail)?; + let stage_file = storage::open(dir, Path::new(&stage))?; + cyber_core::binary::native::copy_metadata(&original, &stage_file).map_err(fail)?; + Self::stage_metadata(&stage_file, after)?; + cyber_core::binary::native::exchange(dir, &stage, &target).map_err(fail)?; + if self.read_entry(dir, std::ffi::OsStr::new(&stage))? != *before { + return Err("Displaced restore preimage changed; recovery required".into()); + } + } + (Some(before), None) => { + rustix::fs::renameat_with( + &parent, + leaf, + dir, + &stage, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(fail)?; + if self.read_entry(dir, std::ffi::OsStr::new(&stage))? != *before { + return Err("Displaced deletion preimage changed; recovery required".into()); + } + } + (None, Some(_)) => rustix::fs::renameat_with( + dir, + &stage, + &parent, + leaf, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(fail)?, + (None, None) => {} + } + storage::sync(&parent)?; + storage::sync(dir)?; + self.verify_root()?; + if storage::dir_identity(&self.parent(&change.path, false)?)? != parent_id { + return Err("Restore parent identity changed; journal retained".into()); + } + if self.current(&change.path)? != change.after { + return Err("Restore result changed; recovery required".into()); + } + Ok(()) + } +} diff --git a/crates/cyber-snapshot/src/copy/storage.rs b/crates/cyber-snapshot/src/copy/storage.rs new file mode 100644 index 00000000..98dd0bdc --- /dev/null +++ b/crates/cyber-snapshot/src/copy/storage.rs @@ -0,0 +1,239 @@ +//! Descriptor-relative private storage. Names are server-derived single components. +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, DirBuilder, DirBuilderExt, OpenOptions, OpenOptionsExt}; +use std::fs::File; +use std::io::{Read, Write}; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +pub type Result = std::result::Result; +pub fn fail(error: impl std::fmt::Display) -> String { + error.to_string() +} +pub fn identity(file: &File) -> Result { + let m = file.metadata().map_err(fail)?; + let birth = m + .created() + .map_err(|_| "Filesystem does not expose stable directory creation identity")?; + let birth = birth.duration_since(std::time::UNIX_EPOCH).map_err(fail)?; + Ok(format!( + "{}:{}:{}:{}", + m.dev(), + m.ino(), + birth.as_secs(), + birth.subsec_nanos() + )) +} +pub fn dir_identity(dir: &Dir) -> Result { + identity(&dir.try_clone().map_err(fail)?.into_std_file()) +} +pub fn sync(dir: &Dir) -> Result<()> { + // cap-std can hold O_PATH directories on Linux, which fsync rejects. + // Reopen the pinned directory itself for I/O, without resolving an ambient path. + let fd = rustix::fs::openat( + dir, + ".", + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::CLOEXEC + | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) + .map_err(fail)?; + File::from(fd).sync_all().map_err(fail) +} +pub fn private_child(parent: &Dir, name: &str) -> Result { + child(parent, name, 0o077) +} +pub fn shared_child(parent: &Dir, name: &str) -> Result { + child(parent, name, 0o022) +} +pub fn existing_child(parent: &Dir, name: &str, private: bool) -> Result { + child_mode(parent, name, if private { 0o077 } else { 0o022 }, false) +} +fn child(parent: &Dir, name: &str, forbidden: u32) -> Result { + child_mode(parent, name, forbidden, true) +} +fn child_mode(parent: &Dir, name: &str, forbidden: u32, create: bool) -> Result { + let mut builder = DirBuilder::new(); + builder.mode(0o700); + if create { + match parent.create_dir_with(name, &builder) { + Ok(()) => sync(parent)?, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(fail(e)), + } + } + let dir = parent.open_dir_nofollow(name).map_err(fail)?; + let m = dir + .try_clone() + .map_err(fail)? + .into_std_file() + .metadata() + .map_err(fail)?; + // Ownership must match the already trusted data-directory descriptor. + let owner = parent + .try_clone() + .map_err(fail)? + .into_std_file() + .metadata() + .map_err(fail)? + .uid(); + if m.uid() != owner || m.mode() & forbidden != 0 { + return Err("Snapshot storage is not private".into()); + } + #[cfg(target_os = "macos")] + if forbidden == 0o077 { + cyber_core::binary::native::validate_private_directory_acl( + &dir.try_clone().map_err(fail)?.into_std_file(), + ) + .map_err(|_| "Snapshot private directory has unsupported ACL grants".to_string())?; + } + Ok(dir) +} +pub fn open(dir: &Dir, name: &Path) -> Result { + let mut options = OpenOptions::new(); + options + .read(true) + .follow(FollowSymlinks::No) + .custom_flags(libc::O_NONBLOCK); + let file = dir.open_with(name, &options).map_err(fail)?.into_std(); + let m = file.metadata().map_err(fail)?; + if !m.is_file() || m.nlink() != 1 { + return Err("Snapshot source is not a unique regular file".into()); + } + Ok(file) +} +pub fn read(dir: &Dir, name: &str, limit: u64) -> Result> { + let file = open(dir, Path::new(name))?; + if file.metadata().map_err(fail)?.len() > limit { + return Err("Snapshot record exceeds limit".into()); + } + let mut bytes = vec![]; + file.take(limit.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(fail)?; + if bytes.len() as u64 > limit { + return Err("Snapshot record grew beyond limit".into()); + } + Ok(bytes) +} +pub fn create(dir: &Dir, name: &str, bytes: &[u8]) -> Result { + let mut options = OpenOptions::new(); + options + .write(true) + .read(true) + .create_new(true) + .mode(0o600) + .follow(FollowSymlinks::No); + let mut file = dir.open_with(name, &options).map_err(fail)?.into_std(); + file.write_all(bytes).map_err(fail)?; + file.sync_all().map_err(fail)?; + #[cfg(target_os = "macos")] + rustix::fs::fcntl_fullfsync(&file).map_err(fail)?; + Ok(file) +} +pub fn atomic(dir: &Dir, name: &str, bytes: &[u8]) -> Result<()> { + let temp = format!("tmp_{}", ulid::Ulid::new()); + create(dir, &temp, bytes)?; + dir.rename(&temp, dir, name).map_err(fail)?; + sync(dir) +} +pub fn lock(dir: &Dir, create: bool) -> Result { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(create) + .mode(0o600) + .follow(FollowSymlinks::No); + let file = dir.open_with("lock", &options).map_err(fail)?.into_std(); + let m = file.metadata().map_err(fail)?; + if !m.is_file() || m.nlink() != 1 || m.mode() & 0o077 != 0 { + return Err("Unsafe snapshot lock".into()); + } + acquire_lock(&file)?; + Ok(file) +} +fn acquire_lock(file: &File) -> Result<()> { + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(250); + loop { + match file.try_lock() { + Ok(()) => return Ok(()), + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(error)) + if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(format!("Cannot lock snapshot storage: {error}")), + } + if std::time::Instant::now() >= deadline { + return Err("Snapshot storage is busy".into()); + } + // Bounded acquisition only: no restore operation has started or is replayed. + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} +pub fn names(dir: &Dir, max: usize) -> Result> { + let mut names = vec![]; + for entry in dir.entries().map_err(fail)? { + if names.len() >= max { + return Err("Snapshot storage entry limit exceeded".into()); + } + names.push( + entry + .map_err(fail)? + .file_name() + .into_string() + .map_err(|_| "Invalid snapshot storage name")?, + ); + } + names.sort(); + Ok(names) +} +pub fn digest(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} +pub fn valid_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directory_sync_uses_a_readable_descriptor() { + let temp = tempfile::tempdir().unwrap(); + let dir = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + #[cfg(target_os = "linux")] + let dir = { + let fd = rustix::fs::openat( + &dir, + ".", + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY, + rustix::fs::Mode::empty(), + ) + .unwrap(); + Dir::from_std_file(File::from(fd)) + }; + sync(&dir).unwrap(); + } + + #[test] + fn lock_acquisition_waits_for_a_short_handoff_but_keeps_a_live_owner_exclusive() { + let temp = tempfile::tempdir().unwrap(); + let dir = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + let owner = lock(&dir, true).unwrap(); + assert!(matches!(lock(&dir, false), Err(error) if error.contains("busy"))); + let contender = dir.try_clone().unwrap(); + let waiting = std::thread::spawn(move || lock(&contender, false)); + std::thread::sleep(std::time::Duration::from_millis(25)); + drop(owner); + let next = waiting.join().unwrap().unwrap(); + assert!(matches!(lock(&dir, false), Err(error) if error.contains("busy"))); + drop(next); + assert!(lock(&dir, false).is_ok()); + } +} diff --git a/crates/cyber-snapshot/src/copy/tree.rs b/crates/cyber-snapshot/src/copy/tree.rs new file mode 100644 index 00000000..63080b8a --- /dev/null +++ b/crates/cyber-snapshot/src/copy/tree.rs @@ -0,0 +1,234 @@ +use super::storage::{self, Result, fail}; +use super::{ENTRY_LIMIT, Entry, Store, Tree}; +use cap_fs_ext::DirExt; +use cap_std::fs::Dir; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::collections::BTreeMap; +use std::io::Read; +use std::os::unix::fs::MetadataExt; +use std::path::{Component, Path}; + +pub(super) fn safe_path(path: &str) -> Result<()> { + if path.is_empty() + || path.len() > 4096 + || path.contains('\\') + || path.contains('\0') + || Path::new(path) + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err("Invalid snapshot path".into()); + } + Ok(()) +} +pub(super) fn protected(path: &Path) -> bool { + path.components() + .any(|c| matches!(c,Component::Normal(n) if n==".git" || n==".cyber" || n==".claude")) +} +impl Store { + pub(super) fn capture(&self) -> Result { + self.verify_root()?; + let mut tree = Tree { + version: 1, + location: self.root.clone(), + root: self.root_id.clone(), + files: BTreeMap::new(), + skipped: vec![], + }; + let mut matcher = GitignoreBuilder::new(&self.root); + for line in &self.options.ignore { + matcher.add_line(None, line).map_err(fail)?; + } + let mut matchers = vec![matcher.build().map_err(fail)?]; + self.walk(&self.work, Path::new(""), &mut matchers, &mut tree, 0)?; + self.verify_root()?; + tree.skipped.sort(); + Ok(tree) + } + fn rules(&self, dir: &Dir, relative: &Path) -> Result { + let mut rules = GitignoreBuilder::new(&self.root); + for name in [".gitignore", ".cyberignore"] { + if dir.symlink_metadata(name).is_err() { + continue; + } + let bytes = storage::read(dir, name, 64 * 1024)?; + let text = std::str::from_utf8(&bytes).map_err(fail)?; + for line in text.lines() { + rules + .add_line(Some(self.root.join(relative).join(name)), line) + .map_err(fail)?; + } + } + rules.build().map_err(fail) + } + fn walk( + &self, + dir: &Dir, + relative: &Path, + matchers: &mut Vec, + tree: &mut Tree, + depth: usize, + ) -> Result<()> { + if depth > 64 { + return Err("Snapshot directory depth limit exceeded".into()); + } + matchers.push(self.rules(dir, relative)?); + for item in dir.entries().map_err(fail)? { + let item = item.map_err(fail)?; + let path = relative.join(item.file_name()); + let name = path + .to_str() + .ok_or("Non-UTF-8 snapshot path is unsupported")? + .to_owned(); + safe_path(&name)?; + if tree.files.len() + tree.skipped.len() >= ENTRY_LIMIT { + return Err("Snapshot path limit exceeded".into()); + } + let metadata = dir.symlink_metadata(item.file_name()).map_err(fail)?; + let excluded = self.root.join(&path).starts_with(&self.data_path) + || protected(&path) + || self + .options + .private_roots + .iter() + .any(|p| self.root.join(&path).starts_with(p)); + let configured_ignore = matchers + .first() + .is_some_and(|r| r.matched(&path, metadata.is_dir()).is_ignore()); + let ignored = configured_ignore + || matchers + .iter() + .skip(1) + .rev() + .map(|r| r.matched(&path, metadata.is_dir())) + .find(|m| !m.is_none()) + .is_some_and(|m| m.is_ignore()); + if excluded || ignored { + tree.skipped.push(format!( + "{}: {name}", + if excluded { "private" } else { "ignore" } + )); + } else if metadata.is_dir() { + let child = dir.open_dir_nofollow(item.file_name()).map_err(fail)?; + self.walk(&child, &path, matchers, tree, depth + 1)?; + } else if !metadata.is_file() + || metadata.file_type().is_symlink() + || cap_fs_ext::MetadataExt::nlink(&metadata) != 1 + { + tree.skipped.push(format!("unsupported: {name}")); + } else if metadata.len() > self.options.max_file { + tree.skipped.push(format!("size: {name}")); + } else { + match self.read_entry(dir, &item.file_name()) { + Ok(entry) => { + tree.files.insert(name, entry); + } + Err(error) if error == "Unsupported snapshot metadata" => { + tree.skipped.push(format!("metadata: {name}")) + } + Err(error) => return Err(error), + } + } + } + matchers.pop(); + Ok(()) + } + pub(super) fn read_entry(&self, dir: &Dir, name: &std::ffi::OsStr) -> Result { + let mut file = storage::open(dir, Path::new(name))?; + let before = file.metadata().map_err(fail)?; + let metadata = cyber_core::binary::native::plain_snapshot_metadata(&file) + .map_err(|_| "Unsupported snapshot metadata")?; + if before.len() > self.options.max_file { + return Err("Snapshot file exceeds configured limit".into()); + } + let mut bytes = vec![]; + Read::by_ref(&mut file) + .take(self.options.max_file.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(fail)?; + let after = file.metadata().map_err(fail)?; + let current = dir.symlink_metadata(name).map_err(fail)?; + if before.len() != bytes.len() as u64 + || before.len() != after.len() + || before.mtime() != after.mtime() + || before.mtime_nsec() != after.mtime_nsec() + || before.ctime() != after.ctime() + || before.ctime_nsec() != after.ctime_nsec() + || before.dev() != cap_fs_ext::MetadataExt::dev(¤t) + || before.ino() != cap_fs_ext::MetadataExt::ino(¤t) + || current.file_type().is_symlink() + || cap_fs_ext::MetadataExt::nlink(¤t) != 1 + { + return Err("Snapshot source changed while hashing".into()); + } + let mut entry = self.object(&bytes)?; + entry.mode = metadata.mode; + entry.uid = metadata.uid; + entry.gid = metadata.gid; + Ok(entry) + } + pub(super) fn parent(&self, path: &str, create: bool) -> Result { + safe_path(path)?; + let parent = Path::new(path).parent().ok_or("Invalid snapshot parent")?; + if protected(Path::new(path)) + || self.root.join(path).starts_with(&self.data_path) + || self + .options + .private_roots + .iter() + .any(|p| self.root.join(path).starts_with(p)) + { + return Err("Protected snapshot path".into()); + } + self.verify_root()?; + let mut dir = self.work.try_clone().map_err(fail)?; + for component in parent.components() { + let Component::Normal(name) = component else { + return Err("Invalid snapshot parent".into()); + }; + if create && dir.symlink_metadata(name).is_err() { + use cap_std::fs::{DirBuilder, DirBuilderExt}; + let mut builder = DirBuilder::new(); + builder.mode(0o700); + dir.create_dir_with(name, &builder).map_err(fail)?; + storage::sync(&dir)?; + #[cfg(target_os = "macos")] + cyber_core::binary::native::validate_private_directory_acl( + &dir.open_dir_nofollow(name).map_err(fail)?.into_std_file(), + ) + .map_err(fail)?; + } + dir = dir.open_dir_nofollow(name).map_err(fail)?; + } + Ok(dir) + } + pub(super) fn current(&self, path: &str) -> Result> { + let parent = match self.parent(path, false) { + Ok(parent) => parent, + Err(error) => { + // Only a genuinely absent ancestor is absence; aliases and errors refuse. + let mut cursor = self.work.try_clone().map_err(fail)?; + for component in Path::new(path) + .parent() + .ok_or("Invalid snapshot path")? + .components() + { + let Component::Normal(name) = component else { + return Err(error); + }; + match cursor.symlink_metadata(name) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + _ => cursor = cursor.open_dir_nofollow(name).map_err(fail)?, + } + } + return Err(error); + } + }; + let leaf = Path::new(path).file_name().ok_or("Invalid snapshot leaf")?; + match parent.symlink_metadata(leaf) { + Ok(_) => self.read_entry(&parent, leaf).map(Some), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(fail(e)), + } + } +} diff --git a/crates/cyber-snapshot/src/lib.rs b/crates/cyber-snapshot/src/lib.rs index f83524f9..c4cc0cc7 100644 --- a/crates/cyber-snapshot/src/lib.rs +++ b/crates/cyber-snapshot/src/lib.rs @@ -1,14 +1,19 @@ -//! Shadow-git working-tree snapshots and conflict-aware restore (`snapshots-checkpoints`). +//! Shadow-Git and private non-Git snapshots with conflict-aware restore. //! //! Each worktree gets a separate git directory under `/snapshot//` that //! uses the project worktree as its work tree and borrows the project's objects through //! alternates. The user's index, refs, stash and config are never touched. +//! Admitted Linux/macOS Locations outside Git use Location-bound BLAKE3 storage. +#[cfg(any(target_os = "macos", target_os = "linux"))] +mod copy; mod git; mod restore; +pub use cyber_server::runtime::SnapshotRecovery; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Duration; use cyber_server::runtime::{FileDiff, RestoreError, Snapshot, Snapshots}; @@ -19,18 +24,38 @@ use git::Shadow; /// Resolved configuration for a Location. pub type ConfigFn = dyn Fn(&Path) -> Value + Send + Sync; +/// Durable roots supplied by the application. Failure disables pruning. +pub type ReferencesFn = dyn Fn() -> Result, String> + Send + Sync; const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; pub struct GitSnapshots { data_dir: PathBuf, config: Arc, + private_roots: Vec, + references: Option>, + unavailable: Mutex>, } impl GitSnapshots { /// `data_dir` is the Cyber data directory; snapshots live under `/snapshot`. pub fn new(data_dir: PathBuf, config: Arc) -> Arc { - Arc::new(Self { data_dir, config }) + Self::with_policy(data_dir, config, vec![], None) + } + + pub fn with_policy( + data_dir: PathBuf, + config: Arc, + private_roots: Vec, + references: Option>, + ) -> Arc { + Arc::new(Self { + data_dir, + config, + private_roots, + references, + unavailable: Mutex::new(HashMap::new()), + }) } fn root(&self) -> PathBuf { @@ -46,8 +71,89 @@ impl GitSnapshots { Shadow::locate(&self.root(), location) } + fn non_git(&self, directory: &str) -> bool { + !self + .private_roots + .iter() + .any(|root| Path::new(directory).starts_with(root)) + && (self.config)(Path::new(directory)).get("snapshots") != Some(&Value::Bool(false)) + && cyber_core::project::worktree_root(Path::new(directory)).is_none() + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + async fn file_copy( + &self, + directory: String, + operation: impl FnOnce(copy::Store) -> Result + Send + 'static, + ) -> Result { + let config = (self.config)(Path::new(&directory)); + if config.get("snapshots") == Some(&Value::Bool(false)) { + return Err("Snapshots are disabled for this Location".into()); + } + let (ignore, max_file) = Self::options_value(&config); + let max_total = config + .pointer("/snapshots/fallback_max_bytes") + .and_then(Value::as_u64) + .unwrap_or(200 * 1024 * 1024); + let data = self.data_dir.clone(); + let private_roots = self.private_roots.clone(); + let lock = cyber_server::workspace::mutation_lock(Path::new(&directory)) + .map_err(|e| e.to_string())?; + let guard = lock.lock_owned().await; + tokio::task::spawn_blocking(move || { + let _guard = guard; + let store = copy::Store::open( + &data, + &directory, + copy::Options { + ignore, + max_file: max_file.min(max_total).min(64 * 1024 * 1024), + max_total, + private_roots, + }, + )?; + operation(store) + }) + .await + .map_err(|e| format!("Snapshot worker did not acknowledge completion: {e}"))? + } + + /// Inspect retained journals without restoring files or acknowledging native effects. + pub async fn recovery(&self, directory: &str) -> Result, String> { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let (ignore, max_file) = self.options(Path::new(directory)); + let max_total = (self.config)(Path::new(directory)) + .pointer("/snapshots/fallback_max_bytes") + .and_then(Value::as_u64) + .unwrap_or(200 * 1024 * 1024); + let options = copy::Options { + ignore, + max_file: max_file.min(max_total).min(64 * 1024 * 1024), + max_total, + private_roots: self.private_roots.clone(), + }; + let data = self.data_dir.clone(); + let directory = directory.to_owned(); + tokio::task::spawn_blocking(move || { + copy::Store::existing(&data, &directory, options)? + .map(|store| store.recovery()) + .unwrap_or_else(|| Ok(vec![])) + }) + .await + .map_err(|e| e.to_string())? + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = directory; + Err("Snapshot recovery inspection is unavailable on this host".into()) + } + } + fn options(&self, location: &Path) -> (Vec, u64) { - let config = (self.config)(location); + Self::options_value(&(self.config)(location)) + } + fn options_value(config: &Value) -> (Vec, u64) { let ignore = config .pointer("/snapshots/ignore") .and_then(Value::as_array) @@ -77,6 +183,16 @@ impl GitSnapshots { } pub async fn gc(&self) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Some(references) = &self.references + && let Ok(protected) = references() + { + let data = self.data_dir.clone(); + let config = self.config.clone(); + let _ = tokio::task::spawn_blocking(move || copy::gc(&data, &config, &protected)).await; + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let _ = &self.references; for git_dir in git::shadow_dirs(&self.root()) { git::gc(&git_dir).await; } @@ -84,9 +200,56 @@ impl GitSnapshots { } impl Snapshots for GitSnapshots { + fn recovery(&self, directory: &str) -> BoxFuture<'_, Result, String>> { + let directory = directory.to_owned(); + Box::pin(async move { GitSnapshots::recovery(self, &directory).await }) + } + fn unavailable_reason(&self, directory: &str) -> Option { + self.unavailable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(directory) + .cloned() + } fn track(&self, directory: &str) -> BoxFuture<'_, Result, String>> { let directory = directory.to_string(); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + let result = self + .file_copy(directory.clone(), |store| store.track()) + .await; + let mut unavailable = self + .unavailable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + return match result { + Ok(snapshot) => { + unavailable.remove(&directory); + Ok(Some(snapshot)) + } + Err(error) => { + let repeated = unavailable.get(&directory) == Some(&error); + if unavailable.len() < 1024 || unavailable.contains_key(&directory) { + unavailable.insert(directory, error.clone()); + } + if repeated { Ok(None) } else { Err(error) } + } + }; + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let mut unavailable = self + .unavailable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if unavailable.len() < 1024 { + unavailable.insert(directory,"Non-Git snapshots require native private storage support on this host; conversation rewind remains available".into()); + } + return Ok(None); + } + } let Some(shadow) = self.shadow(&directory) else { return Ok(None); }; @@ -106,6 +269,14 @@ impl Snapshots for GitSnapshots { ) -> BoxFuture<'_, Result, String>> { let (directory, from, to) = (directory.to_string(), from.to_string(), to.to_string()); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| store.changed(&from, &to)) + .await; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err("Non-Git snapshots are unavailable on this host".into()); + } let Some(shadow) = self.shadow(&directory) else { return Ok(Vec::new()); }; @@ -121,6 +292,14 @@ impl Snapshots for GitSnapshots { ) -> BoxFuture<'_, Result, String>> { let (directory, from, to) = (directory.to_string(), from.to_string(), to.to_string()); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| store.diff(&from, &to)) + .await; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err("Non-Git snapshots are unavailable on this host".into()); + } let Some(shadow) = self.shadow(&directory) else { return Ok(Vec::new()); }; @@ -128,6 +307,23 @@ impl Snapshots for GitSnapshots { }) } + fn exclusions( + &self, + directory: &str, + trees: Vec, + ) -> BoxFuture<'_, Result, String>> { + let directory = directory.to_owned(); + Box::pin(async move { + #[cfg(any(target_os = "macos", target_os = "linux"))] + if self.non_git(&directory) { + return self + .file_copy(directory, move |store| store.exclusions(&trees)) + .await; + } + let _ = (&directory, &trees); + Ok(vec![]) + }) + } fn reviewed_restore_available(&self) -> bool { true } @@ -146,6 +342,19 @@ impl Snapshots for GitSnapshots { expected.to_owned(), ); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| { + Ok(store.preview(&target, &recorded, &expected, None)) + }) + .await + .map_err(RestoreError::Failed)?; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(RestoreError::Failed( + "Non-Git restore is unavailable on this host".into(), + )); + } let shadow = self .shadow(&directory) .ok_or_else(|| RestoreError::Failed("Snapshots are unavailable".into()))?; @@ -179,6 +388,19 @@ impl Snapshots for GitSnapshots { expected.to_owned(), ); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| { + Ok(store.restore(&target, &recorded, &expected, None, authorize.as_ref())) + }) + .await + .map_err(RestoreError::Failed)?; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(RestoreError::Failed( + "Non-Git restore is unavailable on this host".into(), + )); + } let shadow = self.shadow(&directory).ok_or_else(|| { RestoreError::Failed("Snapshots are unavailable for this Location".into()) })?; @@ -222,6 +444,19 @@ impl Snapshots for GitSnapshots { expected.to_owned(), ); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| { + Ok(store.preview(&target, &recorded, &expected, Some(&paths))) + }) + .await + .map_err(RestoreError::Failed)?; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(RestoreError::Failed( + "Non-Git restore is unavailable on this host".into(), + )); + } let shadow = self .shadow(&directory) .ok_or_else(|| RestoreError::Failed("Snapshots are unavailable".into()))?; @@ -255,6 +490,25 @@ impl Snapshots for GitSnapshots { expected.to_owned(), ); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| { + Ok(store.restore( + &target, + &recorded, + &expected, + Some(&paths), + authorize.as_ref(), + )) + }) + .await + .map_err(RestoreError::Failed)?; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(RestoreError::Failed( + "Non-Git restore is unavailable on this host".into(), + )); + } let shadow = self .shadow(&directory) .ok_or_else(|| RestoreError::Failed("Snapshots are unavailable".into()))?; @@ -298,6 +552,24 @@ impl Snapshots for GitSnapshots { recorded.to_string(), ); Box::pin(async move { + if self.non_git(&directory) { + #[cfg(any(target_os = "macos", target_os = "linux"))] + return self + .file_copy(directory, move |store| { + Ok(match store.track() { + Ok(current) => { + store.restore(&target, &recorded, ¤t.tree, None, &|| Ok(())) + } + Err(error) => Err(RestoreError::Failed(error)), + }) + }) + .await + .map_err(RestoreError::Failed)?; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(RestoreError::Failed( + "Non-Git restore is unavailable on this host".into(), + )); + } let shadow = self.shadow(&directory).ok_or_else(|| { RestoreError::Failed("snapshots are unavailable for this Location".into()) })?; diff --git a/crates/cyber-snapshot/tests/file_copy.rs b/crates/cyber-snapshot/tests/file_copy.rs new file mode 100644 index 00000000..5448c968 --- /dev/null +++ b/crates/cyber-snapshot/tests/file_copy.rs @@ -0,0 +1,720 @@ +#![cfg(any(target_os = "macos", target_os = "linux"))] +//! Actual non-Git bytes, namespace boundaries, retained intents and three-way restore. +use cyber_server::runtime::{RestoreError, Snapshot, Snapshots}; +use cyber_snapshot::GitSnapshots; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +struct Project { + _temp: tempfile::TempDir, + root: PathBuf, + data: PathBuf, + config: Arc>, + snapshots: Arc, +} +impl Project { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path().canonicalize().unwrap(); + let root = base.join("work"); + let data = base.join("data"); + std::fs::create_dir(&root).unwrap(); + let config = Arc::new(Mutex::new(json!({}))); + let shared = config.clone(); + let snapshots = GitSnapshots::new( + data.clone(), + Arc::new(move |_| shared.lock().unwrap().clone()), + ); + Self { + _temp: temp, + root, + data, + config, + snapshots, + } + } + fn dir(&self) -> String { + self.root.display().to_string() + } + fn write(&self, path: &str, bytes: impl AsRef<[u8]>) { + let path = self.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + async fn track(&self) -> Snapshot { + self.snapshots.track(&self.dir()).await.unwrap().unwrap() + } +} +#[tokio::test] +async fn non_git_snapshots_hash_bytes_preserve_owner_edits_and_restore_without_a_repository() { + let p = Project::new(); + p.write("src/a.txt", "one\ntwo\nthree\nfour\nfive\nsix\n"); + let before = p.track().await; + assert!(before.tree.starts_with("copy:")); + p.write("src/a.txt", "ONE\ntwo\nthree\nfour\nfive\nsix\n"); + p.write("new.bin", [0, 255, 128]); + let after = p.track().await; + p.write("src/a.txt", "ONE\ntwo\nthree\nfour\nfive\nSIX\n"); + p.write("owner.txt", "keep\n"); + let current = p.track().await; + let preview = p + .snapshots + .preview_restore(&p.dir(), &before.tree, &after.tree, ¤t.tree) + .await + .unwrap(); + assert_eq!(preview.len(), 2); + assert!(!preview.iter().any(|f| f.file == "owner.txt")); + assert!( + std::fs::read_to_string(p.root.join("src/a.txt")) + .unwrap() + .starts_with("ONE") + ); + let result = p + .snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + ¤t.tree, + Arc::new(|| Ok(())), + ) + .await + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + std::fs::read_to_string(p.root.join("src/a.txt")).unwrap(), + "one\ntwo\nthree\nfour\nfive\nSIX\n" + ); + assert!(!p.root.join("new.bin").exists()); + assert_eq!( + std::fs::read_to_string(p.root.join("owner.txt")).unwrap(), + "keep\n" + ); + assert!(!p.root.join(".git").exists()); + assert!( + p.snapshots + .recovery(&p.dir()) + .await + .unwrap() + .iter() + .all(|j| j.filesystem_complete) + ); +} +#[tokio::test] +async fn same_size_same_timestamp_is_hashed_and_old_previews_refuse() { + let p = Project::new(); + p.write("a.txt", "aaaa"); + let before = p.track().await; + let file = std::fs::OpenOptions::new() + .write(true) + .open(p.root.join("a.txt")) + .unwrap(); + let timestamp = file.metadata().unwrap().modified().unwrap(); + p.write("a.txt", "bbbb"); + file.set_times(std::fs::FileTimes::new().set_modified(timestamp)) + .unwrap(); + let after = p.track().await; + assert_ne!(before.tree, after.tree); + assert_eq!( + p.snapshots + .changed(&p.dir(), &before.tree, &after.tree) + .await + .unwrap(), + ["a.txt"] + ); + p.write("a.txt", "cccc"); + assert!(matches!( + p.snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + &after.tree, + Arc::new(|| Ok(())) + ) + .await, + Err(RestoreError::Stale) + )); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"cccc"); +} +#[tokio::test] +async fn ignores_size_private_roots_and_aliases_are_disclosed_and_never_captured() { + use std::os::unix::fs::symlink; + let p = Project::new(); + *p.config.lock().unwrap() = + json!({"snapshots":{"ignore":["configured.txt"],"max_file_bytes":64}}); + p.write(".gitignore", "ignored/\n"); + p.write("ignored/file", "hidden"); + p.write("configured.txt", "hidden"); + p.write("large.bin", vec![1; 65]); + p.write(".cyber/private", "secret"); + p.write("plain", "visible"); + symlink(p.root.join(".cyber"), p.root.join("alias")).unwrap(); + std::fs::hard_link(p.root.join("plain"), p.root.join("hard-link")).unwrap(); + let snapshot = p.track().await; + for name in [ + "ignored", + "configured.txt", + "large.bin", + ".cyber", + "alias", + "hard-link", + "plain", + ] { + assert!( + snapshot.skipped.iter().any(|s| s.ends_with(name)), + "{name}: {:?}", + snapshot.skipped + ); + } + let root = p.data.join("snapshot/global"); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(root).unwrap().permissions().mode() & 0o777, + 0o700 + ); +} +#[tokio::test] +async fn storage_cap_and_foreign_location_do_not_authorize_restore() { + let p = Project::new(); + p.write("a.txt", "before"); + let before = p.track().await; + let other = Project::new(); + other.write("a.txt", "other"); + let current = other.track().await; + assert!( + other + .snapshots + .preview_restore(&other.dir(), &before.tree, ¤t.tree, ¤t.tree) + .await + .is_err() + ); + *p.config.lock().unwrap() = json!({"snapshots":{"fallback_max_bytes":1}}); + p.write("b.txt", "cap exceeds"); + let err = p.snapshots.track(&p.dir()).await.unwrap_err(); + assert!(err.contains("cap reached"), "{err}"); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"before"); +} +#[tokio::test] +async fn admission_loss_after_a_replacement_retains_journal_and_blocks_replay() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let p = Project::new(); + p.write("a.txt", "old a"); + p.write("b.txt", "old b"); + let before = p.track().await; + p.write("a.txt", "new a"); + p.write("b.txt", "new b"); + let after = p.track().await; + let calls = Arc::new(AtomicUsize::new(0)); + let count = calls.clone(); + let result = p + .snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + &after.tree, + Arc::new(move || { + if count.fetch_add(1, Ordering::SeqCst) >= 2 { + Err("admission revoked".into()) + } else { + Ok(()) + } + }), + ) + .await; + assert!(result.is_err()); + let journal = p.snapshots.recovery(&p.dir()).await.unwrap(); + assert_eq!(journal.len(), 1); + assert!(!journal[0].filesystem_complete); + assert_eq!(journal[0].applied, ["a.txt"]); + let next = p.snapshots.track(&p.dir()).await.unwrap_err(); + assert!(next.contains("requires recovery"), "{next}"); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"old a"); + assert_eq!(std::fs::read(p.root.join("b.txt")).unwrap(), b"new b"); +} + +fn namespace(p: &Project) -> PathBuf { + std::fs::read_dir(p.data.join("snapshot/global")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path() +} +#[tokio::test] +async fn retention_prunes_orphans_but_preserves_durable_reference_roots() { + use std::collections::BTreeSet; + let mut p = Project::new(); + p.write("a.txt", "first"); + let first = p.track().await; + p.write("a.txt", "second"); + let second = p.track().await; + let manifests = namespace(&p).join("manifests"); + for file in std::fs::read_dir(&manifests).unwrap() { + let path = file.unwrap().path(); + let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + value["accessed"] = json!(0); + std::fs::write(path, value.to_string()).unwrap(); + } + let orphan = format!("tmp_{}", ulid::Ulid::new()); + for directory in [&manifests, &namespace(&p).join("files")] { + let path = directory.join(&orphan); + std::fs::write(&path, b"unpublished").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + } + let protected = BTreeSet::from([first.tree.clone()]); + let config = p.config.clone(); + p.snapshots = GitSnapshots::with_policy( + p.data.clone(), + Arc::new(move |_| config.lock().unwrap().clone()), + vec![], + Some(Arc::new(move || Ok(protected.clone()))), + ); + p.snapshots.gc().await; + assert!(!manifests.join(&orphan).exists()); + assert!(!namespace(&p).join("files").join(&orphan).exists()); + assert!( + manifests + .join(first.tree.strip_prefix("copy:").unwrap()) + .exists() + ); + assert!( + !manifests + .join(second.tree.strip_prefix("copy:").unwrap()) + .exists() + ); + assert!( + p.snapshots + .diff(&p.dir(), &first.tree, &first.tree) + .await + .unwrap() + .is_empty() + ); +} +#[tokio::test] +async fn configured_private_roots_and_parent_symlink_replacement_refuse_restore() { + use std::os::unix::fs::symlink; + let mut p = Project::new(); + let private = p.root.join("operator-state"); + std::fs::create_dir(&private).unwrap(); + p.write("operator-state/password", "secret"); + p.snapshots = + GitSnapshots::with_policy(p.data.clone(), Arc::new(|_| json!({})), vec![private], None); + p.write("nested/a.txt", "first"); + let first = p.track().await; + assert!(first.skipped.iter().any(|s| s.ends_with("operator-state"))); + p.write("nested/a.txt", "second"); + let second = p.track().await; + std::fs::rename(p.root.join("nested"), p.root.join("moved")).unwrap(); + symlink(p.root.join("moved"), p.root.join("nested")).unwrap(); + assert!( + p.snapshots + .restore_reviewed( + &p.dir(), + &first.tree, + &second.tree, + &second.tree, + Arc::new(|| Ok(())) + ) + .await + .is_err() + ); + assert_eq!( + std::fs::read(p.root.join("moved/a.txt")).unwrap(), + b"second" + ); +} + +/// Child-only worker. The parent kills it while admission waits between two +/// actual native replacements; there is no production crash switch. +#[test] +fn killed_restore_worker() { + let Some(base) = std::env::var_os("CYBER_COPY_CRASH_TEST") else { + return; + }; + let base = PathBuf::from(base); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let snapshots = GitSnapshots::new(base.join("data"), Arc::new(|_| json!({}))); + let directory = base.join("work").display().to_string(); + let ids: Value = + serde_json::from_slice(&std::fs::read(base.join("ids.json")).unwrap()).unwrap(); + let marker = base.join("restore-paused"); + let count = std::sync::atomic::AtomicUsize::new(0); + let _ = snapshots + .restore_reviewed( + &directory, + ids[0].as_str().unwrap(), + ids[1].as_str().unwrap(), + ids[1].as_str().unwrap(), + Arc::new(move || { + if count.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 2 { + std::fs::write(&marker, b"first native replacement acknowledged").unwrap(); + loop { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + Ok(()) + }), + ) + .await; + }); +} +#[tokio::test] +async fn process_kill_between_replacements_retains_preimages_and_never_replays() { + let p = Project::new(); + p.write("a.txt", "before a"); + p.write("b.txt", "before b"); + let before = p.track().await; + p.write("a.txt", "after a"); + p.write("b.txt", "after b"); + let after = p.track().await; + let base = p.root.parent().unwrap(); + std::fs::write( + base.join("ids.json"), + json!([before.tree, after.tree]).to_string(), + ) + .unwrap(); + let mut worker = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "killed_restore_worker", "--nocapture"]) + .env("CYBER_COPY_CRASH_TEST", base) + .env("PATH", "") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !base.join("restore-paused").exists() { + if std::time::Instant::now() >= deadline || worker.try_wait().unwrap().is_some() { + let _ = worker.kill(); + let _ = worker.wait(); + panic!("native worker did not reach the between-file cut"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + worker.kill().unwrap(); + worker.wait().unwrap(); + let reopened = GitSnapshots::new(p.data.clone(), Arc::new(|_| json!({}))); + let recovery = reopened.recovery(&p.dir()).await.unwrap(); + assert_eq!(recovery.len(), 1); + assert!(!recovery[0].filesystem_complete); + assert_eq!(recovery[0].applied, ["a.txt"]); + assert!( + reopened + .track(&p.dir()) + .await + .unwrap_err() + .contains("requires recovery") + ); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"before a"); + assert_eq!(std::fs::read(p.root.join("b.txt")).unwrap(), b"after b"); + let journal = namespace(&p).join("journals").join(&recovery[0].id); + assert_eq!(std::fs::read(journal.join("stage_0")).unwrap(), b"after a"); +} + +#[tokio::test] +async fn historical_exclusions_do_not_become_deletions_and_configured_ignores_take_priority() { + let p = Project::new(); + p.write("a.txt", "abc"); + let before = p.track().await; + *p.config.lock().unwrap() = json!({"snapshots":{"max_file_bytes":4,"ignore":["secret.txt"]}}); + p.write("a.txt", "oversize"); + p.write("secret.txt", "key"); + p.write(".gitignore", "!secret.txt\n"); + let after = p.track().await; + assert!(after.skipped.iter().any(|p| p == "size: a.txt")); + assert!(after.skipped.iter().any(|p| p == "ignore: secret.txt")); + assert!( + !p.snapshots + .changed(&p.dir(), &before.tree, &after.tree) + .await + .unwrap() + .contains(&"a.txt".into()) + ); + assert!( + p.snapshots + .exclusions(&p.dir(), vec![after.tree]) + .await + .unwrap() + .iter() + .any(|p| p.ends_with("size: a.txt")) + ); +} +#[tokio::test] +async fn executable_mode_is_reviewed_and_restored() { + use std::os::unix::fs::PermissionsExt; + let p = Project::new(); + p.write("run.sh", "echo done\n"); + std::fs::set_permissions( + p.root.join("run.sh"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + let before = p.track().await; + std::fs::set_permissions( + p.root.join("run.sh"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let after = p.track().await; + let preview = p + .snapshots + .preview_restore(&p.dir(), &before.tree, &after.tree, &after.tree) + .await + .unwrap(); + assert!(preview[0].patch.contains("old mode 0755")); + assert!(preview[0].patch.contains("new mode 0644")); + p.snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + &after.tree, + Arc::new(|| Ok(())), + ) + .await + .unwrap(); + assert_eq!( + std::fs::metadata(p.root.join("run.sh")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o644 + ); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn macos_acl_grants_cannot_make_private_snapshot_storage_readable() { + use std::os::unix::fs::PermissionsExt; + let p = Project::new(); + p.write("a.txt", "secret bytes"); + let global = p.data.join("snapshot/global"); + std::fs::create_dir_all(&global).unwrap(); + std::fs::set_permissions(&global, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = std::process::Command::new("chmod") + .args(["+a", "everyone allow read,search"]) + .arg(&global) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + let error = p.snapshots.track(&p.dir()).await.unwrap_err(); + assert!(error.contains("ACL grants"), "{error}"); + assert_eq!(std::fs::read_dir(global).unwrap().count(), 0); +} + +#[tokio::test] +async fn recovery_inspection_does_not_wait_for_a_paused_native_writer() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + struct Release(Arc); + impl Drop for Release { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let p = Project::new(); + p.write("a.txt", "old a"); + p.write("b.txt", "old b"); + let before = p.track().await; + p.write("a.txt", "new a"); + p.write("b.txt", "new b"); + let after = p.track().await; + let released = Arc::new(AtomicBool::new(false)); + let _release = Release(released.clone()); + let paused = Arc::new(tokio::sync::Notify::new()); + let signal = paused.clone(); + let snapshots = p.snapshots.clone(); + let directory = p.dir(); + let resume = released.clone(); + let task = tokio::spawn(async move { + let count = AtomicUsize::new(0); + snapshots + .restore_reviewed( + &directory, + &before.tree, + &after.tree, + &after.tree, + Arc::new(move || { + if count.fetch_add(1, Ordering::SeqCst) == 2 { + signal.notify_one(); + while !resume.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + Ok(()) + }), + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(10), paused.notified()) + .await + .unwrap(); + let view = tokio::time::timeout( + std::time::Duration::from_secs(2), + p.snapshots.recovery(&p.dir()), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(view.len(), 1); + assert!(!view[0].filesystem_complete); + assert_eq!(view[0].applied, ["a.txt"]); + released.store(true, Ordering::SeqCst); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn deleted_parent_is_recreated_privately_with_original_file_ownership_and_mode() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let p = Project::new(); + p.write("private/a.txt", "private source"); + std::fs::set_permissions( + p.root.join("private"), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + std::fs::set_permissions( + p.root.join("private/a.txt"), + std::fs::Permissions::from_mode(0o640), + ) + .unwrap(); + let metadata = std::fs::metadata(p.root.join("private/a.txt")).unwrap(); + let before = p.track().await; + std::fs::remove_dir_all(p.root.join("private")).unwrap(); + let after = p.track().await; + p.snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + &after.tree, + Arc::new(|| Ok(())), + ) + .await + .unwrap(); + let restored = std::fs::metadata(p.root.join("private/a.txt")).unwrap(); + assert_eq!( + (restored.uid(), restored.gid(), restored.mode() & 0o777), + (metadata.uid(), metadata.gid(), 0o640) + ); + assert_eq!( + std::fs::metadata(p.root.join("private")).unwrap().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::read(p.root.join("private/a.txt")).unwrap(), + b"private source" + ); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn file_acl_is_excluded_instead_of_recreated_with_wider_access() { + let p = Project::new(); + p.write("sensitive", "private source"); + let result = std::process::Command::new("chmod") + .args(["+a", "everyone deny write"]) + .arg(p.root.join("sensitive")) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + let snapshot = p.track().await; + assert!(snapshot.skipped.iter().any(|s| s == "metadata: sensitive")); + assert_eq!( + std::fs::read_dir(namespace(&p).join("files")) + .unwrap() + .count(), + 0 + ); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn extended_metadata_is_excluded_instead_of_dropped_on_recreation() { + let p = Project::new(); + p.write("sensitive", "private source"); + let file = std::fs::File::open(p.root.join("sensitive")).unwrap(); + rustix::fs::fsetxattr( + &file, + "user.cyber-snapshot-test", + b"metadata", + rustix::fs::XattrFlags::empty(), + ) + .unwrap(); + let snapshot = p.track().await; + assert!(snapshot.skipped.iter().any(|s| s == "metadata: sensitive")); + assert_eq!( + std::fs::read_dir(namespace(&p).join("files")) + .unwrap() + .count(), + 0 + ); +} + +#[tokio::test] +async fn conflicting_owner_edit_refuses_the_entire_restore_before_replacement() { + let p = Project::new(); + p.write("a.txt", "old a\n"); + p.write("b.txt", "base\n"); + let before = p.track().await; + p.write("a.txt", "new a\n"); + p.write("b.txt", "agent\n"); + let after = p.track().await; + p.write("b.txt", "owner\n"); + let current = p.track().await; + let result = p + .snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + ¤t.tree, + Arc::new(|| Ok(())), + ) + .await; + assert!(matches!(result, Err(RestoreError::Conflict(paths)) if paths == ["b.txt"])); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"new a\n"); + assert_eq!(std::fs::read(p.root.join("b.txt")).unwrap(), b"owner\n"); + assert!(p.snapshots.recovery(&p.dir()).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn restore_reserves_displaced_preimages_before_changing_source_files() { + let p = Project::new(); + p.write("a.txt", "old a\n"); + let before = p.track().await; + p.write("a.txt", "new a\n"); + let after = p.track().await; + let mut used = 0u64; + for name in ["files", "manifests"] { + for entry in std::fs::read_dir(namespace(&p).join(name)).unwrap() { + used += entry.unwrap().metadata().unwrap().len(); + } + } + *p.config.lock().unwrap() = json!({"snapshots":{"fallback_max_bytes":used}}); + let result = p + .snapshots + .restore_reviewed( + &p.dir(), + &before.tree, + &after.tree, + &after.tree, + Arc::new(|| Ok(())), + ) + .await; + assert!( + matches!(result, Err(RestoreError::Failed(message)) if message.contains("cap reached")) + ); + assert_eq!(std::fs::read(p.root.join("a.txt")).unwrap(), b"new a\n"); + assert!(p.snapshots.recovery(&p.dir()).await.unwrap().is_empty()); +} diff --git a/crates/cyber-snapshot/tests/file_copy_platform.rs b/crates/cyber-snapshot/tests/file_copy_platform.rs new file mode 100644 index 00000000..04cd44c6 --- /dev/null +++ b/crates/cyber-snapshot/tests/file_copy_platform.rs @@ -0,0 +1,21 @@ +//! Unsupported native storage stays explicit without failing conversation capture. +#[cfg(windows)] +#[tokio::test] +async fn non_git_windows_capture_is_unavailable_with_an_actionable_reason() { + use cyber_server::runtime::Snapshots; + use std::sync::Arc; + let root = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let snapshots = + cyber_snapshot::GitSnapshots::new(data.path().into(), Arc::new(|_| serde_json::json!({}))); + let directory = root.path().display().to_string(); + assert!(snapshots.track(&directory).await.unwrap().is_none()); + assert!( + snapshots + .unavailable_reason(&directory) + .unwrap() + .contains("native private storage") + ); + assert!(snapshots.track(&directory).await.unwrap().is_none()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); +} diff --git a/crates/cyber-snapshot/tests/snapshots.rs b/crates/cyber-snapshot/tests/snapshots.rs index e9626273..46bd5390 100644 --- a/crates/cyber-snapshot/tests/snapshots.rs +++ b/crates/cyber-snapshot/tests/snapshots.rs @@ -199,12 +199,11 @@ async fn restore_brings_back_deleted_files_and_keeps_a_backup() { } #[tokio::test] -async fn disabled_or_non_git_locations_take_no_snapshots() { +async fn disabled_locations_take_no_snapshots() { let r = Repo::new(); *r.config.lock().unwrap() = json!({"snapshots": false}); assert_eq!(r.snaps.track(&r.dir()).await.unwrap(), None); let plain = tempfile::tempdir().unwrap(); - *r.config.lock().unwrap() = json!({}); assert_eq!( r.snaps .track(&plain.path().display().to_string()) diff --git a/crates/cyber-tui/src/app.rs b/crates/cyber-tui/src/app.rs index cbf9ee52..8aaa74de 100644 --- a/crates/cyber-tui/src/app.rs +++ b/crates/cyber-tui/src/app.rs @@ -2367,6 +2367,15 @@ impl App { )); Vec::new() } + "session.error" if data["kind"] == "snapshot" => { + self.toast(format!( + "snapshot warning: {}", + data["message"] + .as_str() + .unwrap_or("code rewind unavailable") + )); + vec![Action::Refresh] + } "session.error" => { let text = format!("error: {}", data["message"].as_str().unwrap_or_default()); self.alert(crate::sound::Alert::Error, &format!("cyber: {text}")); diff --git a/crates/cyber-tui/src/rewind.rs b/crates/cyber-tui/src/rewind.rs index 705257f5..b593f00c 100644 --- a/crates/cyber-tui/src/rewind.rs +++ b/crates/cyber-tui/src/rewind.rs @@ -567,6 +567,41 @@ mod tests { assert!(matches!(app.overlay, Overlay::None)); } #[test] + fn optional_snapshot_warning_keeps_the_running_session_and_draft() { + let mut app = app(); + app.session.running = true; + app.composer.set_text("keep this draft"); + let actions = app.on_event( + "session.error", + &serde_json::json!({"kind":"snapshot","message":"storage cap reached"}), + ); + assert!(app.session.running); + assert_eq!(app.composer.text(), "keep this draft"); + assert_eq!(actions, vec![Action::Refresh]); + assert!(app.toast.as_ref().unwrap().0.contains("snapshot warning")); + } + #[test] + fn history_search_cancel_and_snapshot_warning_do_not_arm_rewind_or_replace_the_draft() { + let mut app = app(); + app.composer = crate::composer::Composer::new(vec!["older prompt".into()]); + app.composer.set_text("keep this draft"); + app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(app.history_search.is_some()); + app.on_event( + "session.error", + &serde_json::json!({"kind":"snapshot","message":"cap reached"}), + ); + assert!(app.history_search.is_some()); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert!(app.on_key(esc).is_empty()); + assert!(app.history_search.is_none()); + assert!(app.rewind.escape.is_none()); + assert_eq!(app.composer.text(), "keep this draft"); + assert!(app.on_key(esc).is_empty()); + assert!(matches!(app.on_key(esc).as_slice(), [Action::Rewind(_)])); + assert_eq!(app.composer.text(), "keep this draft"); + } + #[test] fn permission_and_attachment_drafts_keep_their_priority() { let mut app = app(); app.overlay = Overlay::Permission(crate::app::PermStep::Choose(0)); diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_light.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_light.snap index eacd2d8d..4d1db251 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_light.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_light.snap @@ -10,7 +10,7 @@ expression: shown │command or compaction. Clear reverses the stage while preserving later owner edits. External │ │effects and unresolved tool/native outcomes are not undone or acknowledged. │ │Composer after acknowledgement (newer edits stay): WRITE the notes │ -│1 proposed file changes · 0 current exclusions │ +│1 proposed file changes · 0 snapshot exclusions │ │deleted notes.md (+0 -3) │ │diff --git a/notes.md b/notes.md │ │deleted file mode 100644 │ diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_narrow.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_narrow.snap index 98e0934e..0394b4f8 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_narrow.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_narrow.snap @@ -14,7 +14,7 @@ expression: shown │undone or acknowledged. │ │Composer after acknowledgement (newer edits stay): WRITE the │ │notes │ -│1 proposed file changes · 0 current exclusions │ +│1 proposed file changes · 0 snapshot exclusions │ │deleted notes.md (+0 -3) │ │diff --git a/notes.md b/notes.md │ │deleted file mode 100644 │ diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_no_color.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_no_color.snap index eacd2d8d..4d1db251 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_no_color.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_no_color.snap @@ -10,7 +10,7 @@ expression: shown │command or compaction. Clear reverses the stage while preserving later owner edits. External │ │effects and unresolved tool/native outcomes are not undone or acknowledged. │ │Composer after acknowledgement (newer edits stay): WRITE the notes │ -│1 proposed file changes · 0 current exclusions │ +│1 proposed file changes · 0 snapshot exclusions │ │deleted notes.md (+0 -3) │ │diff --git a/notes.md b/notes.md │ │deleted file mode 100644 │ diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_screen_reader.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_screen_reader.snap index 0ef54edc..38d82b47 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_screen_reader.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_screen_reader.snap @@ -10,7 +10,7 @@ expression: "render(app, 100, 30, &Opts::default())" │command or compaction. Clear reverses the stage while preserving later owner edits. External │ │effects and unresolved tool/native outcomes are not undone or acknowledged. │ │Composer after acknowledgement (newer edits stay): WRITE the notes │ -│1 proposed file changes · 0 current exclusions │ +│1 proposed file changes · 0 snapshot exclusions │ │deleted notes.md (+0 -3) │ │diff --git a/notes.md b/notes.md │ │deleted file mode 100644 │ diff --git a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_wide.snap b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_wide.snap index 05b1b686..5cbd25c2 100644 --- a/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_wide.snap +++ b/crates/cyber-tui/src/snapshots/cyber_tui__screens_explorer__rewind_review_wide.snap @@ -9,7 +9,7 @@ expression: shown │Files are restored when selected; the conversation boundary is staged until the next prompt, shell command or compaction. Clear reverses │ │the stage while preserving later owner edits. External effects and unresolved tool/native outcomes are not undone or acknowledged. │ │Composer after acknowledgement (newer edits stay): WRITE the notes │ -│1 proposed file changes · 0 current exclusions │ +│1 proposed file changes · 0 snapshot exclusions │ │deleted notes.md (+0 -3) │ │diff --git a/notes.md b/notes.md │ │deleted file mode 100644 │ diff --git a/crates/cyber-tui/src/view/rewind.rs b/crates/cyber-tui/src/view/rewind.rs index 4c54205e..37e6f1cd 100644 --- a/crates/cyber-tui/src/view/rewind.rs +++ b/crates/cyber-tui/src/view/rewind.rs @@ -140,7 +140,7 @@ fn review(app: &App, lines: &mut Vec>) { } if let Some(code) = &preview.code { lines.push(Line::from(format!( - "{} proposed file changes · {} current exclusions", + "{} proposed file changes · {} snapshot exclusions", code.files.len(), code.skipped.len() ))); diff --git a/docs/features.md b/docs/features.md index 9ebf0acf..0cfc3c8e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,7 +20,7 @@ Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-sess | Base prompts and output styles | Embedded base prompts chosen by model family (`openai`, `anthropic`, `gemini`, `local`, `default`) from the catalog model, not the provider; built-in styles `default`, `concise`, `explanatory`, `learning` and custom `~/.config/cyber/output-styles/*.md` and `.cyber/output-styles/*.md` (validated names and frontmatter, 32 KiB regular files, project styles held until the checkout is trusted and part of its digest); selection by Session switch, creation, `output_style` config, then `default`, for primary Sessions only; `/output-style` picker, the Settings Config row, `cyber exec --output-style`, `GET /config/output-styles`, `PUT /config/output-style` and `POST /sessions/{id}/output-style`; a family, style or base prompt version change starts a new Context Epoch at the next Turn ([guide](local-core-guide.md#base-prompts-and-output-styles)) | Available; plugin styles, `cyber import` of `.claude/output-styles` and per-agent styles remain | | Safety | Workspace trust, permission rules, protected paths, auto classification with confirmed `/approve`, macOS/Linux sandboxing and Windows AppContainer confinement with named profiles (`deny_read`), the environment policy, credential masking, `Sandbox denied:` reports with one-call escalation, excluded commands, `sandbox.*` events, `cyber sandbox explain/test/run`, the `bypass` eligibility gate (sandbox, container or `--dangerously-bypass-permissions`), org `policy.modes.disable` and the rule dry run (`cyber permissions test`, `GET /permissions/test`) | Available; on Windows, sandboxed Git Bash and PowerShell cannot start in the AppContainer yet ([issue #9](https://github.com/CyberdyneCorp/cybercode-cli/issues/9)), and the Windows proxy and full auto-mode controls remain partial P1 | | Untrusted content | `webfetch`, `websearch`, MCP tool, browser and MCP resource results arrive in one `` frame that content cannot close, with hidden characters shown as `[U+XXXX]` markers and overflow re-reads kept framed; a deterministic injection signal detector (seven kinds, `detector_version` 2) records `untrusted` on `session.tool.settled.1` and admitted messages, with a warning line in the TUI, the web dashboard and `cyber exec` (stderr and stream-json); after a signal the run is tainted until the next user prompt, and egress, external writes, classifier-allowed commands and secret reads ask (or are denied with no user attached, in `dont-ask`, or in `bypass` with `bypass: "deny"`), with taint evidence for the auto classifier; `permissions.untrusted_content` (`taint`, `bypass`, `scan`, `allow_when_tainted`, project layers may only tighten); base prompt guidance (version 3); a labeled fixture set with a recall and false-positive gate and scripted end-to-end scenarios ([guide](local-core-guide.md#untrusted-content)) | Done locally (P1, `add-untrusted-content-defense`); the TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` (`untrusted_content` on `GET /sessions/:id`); hand acceptance on the real binary remains | -| File recovery | Shadow-git snapshots, conflict-aware restore and [reviewed rewind](rewind.md) with explicit fingerprint confirmation | CLI and TUI code/conversation/both; idle Esc-Esc, staged `/undo`/`/redo` and guarded composer restoration. Private non-Git snapshots and broader recovery acceptance remain | +| File recovery | Shadow-git snapshots, conflict-aware restore and [reviewed rewind](rewind.md) with explicit fingerprint confirmation | CLI and TUI code/conversation/both; idle Esc-Esc, staged `/undo`/`/redo` and guarded composer restoration. Private Linux/macOS file-copy snapshots, retained restore intents and read-only recovery inspection are implemented; managed resolution, journal reclamation and broader recovery acceptance remain | | Project collaboration | Self-hosted Git project hub, verified organization/friends membership, explicit published local agents, human/agent project and private conversations, held receiver consent, fenced shared tasks, CLI `/collab` TUI, the `/agents` Project hub group and typed SDK ([guide](collaboration.md), [evidence](implementation/project-collaboration.md)) | Experimental; off by default. Live Auth provisioning and multi-host deployment acceptance remain operator work | | Clients | TUI, noninteractive `exec`, background service, HTTP/SSE/WebSocket/stdio API and generated TypeScript SDK | Available; complete P1 client surfaces remain | | Installation and upgrade | Signed releases from `.github/workflows/release.yml` on a `v*` tag: 8 targets, `SHA256SUMS`, cosign keyless bundles and a rolling `channel-` manifest on GitHub Releases; `install.sh` (POSIX `sh`, SHA-256 and optional cosign checks, atomic install, marked PATH line, `CYBER_INSTALL_BASE_URL` mirrors); `cyber upgrade` (verified, atomic self-replace or the package manager's command), `cyber uninstall` (sizes, confirmation, no symlink following, keyring cleanup), background update notices and the `min_supported` warning; a `Dockerfile` running as uid 10001 with `CI=true` ([guide](local-core-guide.md#install-upgrade-and-uninstall)) | P0 done locally; Homebrew, npm, Scoop, WinGet, AUR, Nix and container-registry publishing are partial (manifests rendered, not published); no release has been tagged yet | diff --git a/docs/rewind.md b/docs/rewind.md index 1ad429b4..41f27a00 100644 --- a/docs/rewind.md +++ b/docs/rewind.md @@ -18,11 +18,11 @@ keeps the conversation; `conversation` moves the active path before that message `cyber sessions tree`, checkout and `export --all-branches`. The fingerprint binds the Session, exact Location, sequence, boundary, active tip, -snapshot identities, proposed patches, current exclusions and retained tool status. -A changed context requires a fresh review. Code planning uses the existing +snapshot identities, proposed patches, available current/historical exclusions and retained tool status. +A changed context requires a fresh review. Code planning uses three-way merge rules; unrelated owner files stay intact and are not shown as -proposed deletions. Conflicts are reported before application. Planning uses a -separate shadow Git index and never installs proposed files into the workspace. +proposed deletions. Conflicts are reported before application. Git planning uses a separate shadow index; file-copy planning uses private objects. +Neither installs proposed files into the workspace. Confirmation obtains native Location ownership, rechecks the current tree under the workspace mutation lock and refreshes admission before file changes. A failed @@ -30,13 +30,20 @@ native acknowledgement or event commit after a possible effect retains the nativ activity fence and backup evidence; it is not a successful rewind. Do not repeat a mutation to infer its outcome. Inspect the Session and retained recovery evidence. -Code rewind currently needs an available Git snapshot with an implementing -reviewed-restore host. Non-Git Locations and disabled snapshots support -conversation-only rewind; their code rewind fails visibly. Current snapshot -exclusions are listed; complete historical exclusion reporting remains acceptance -work. Legacy stages keep their original owner-route behavior. A reviewed file stage -cannot be replaced or cleared through legacy stage/clear routes, or discarded by -branch checkout; use a reviewed clear or explicitly commit it. +Code rewind uses shadow Git snapshots in repositories. On Linux/macOS, an +admitted non-Git Location uses private BLAKE3 file-copy snapshots. The filesystem +must expose stable directory creation identity and support native metadata and +replacement operations; restore currently needs the Location and Cyber data on +the same filesystem. Unsupported cases refuse code restore visibly. Windows +currently supports Git snapshots and conversation-only rewind outside Git. +Disabled snapshots also retain conversation-only rewind. + +Current exclusions are listed; file-copy previews also disclose exclusions from +the target and recorded manifests. Complete historical Git exclusion reporting +remains unfinished. Legacy stages keep their original owner-route behavior. A +reviewed file stage cannot be replaced or cleared through legacy stage/clear +routes, or discarded by branch checkout; use a reviewed clear or explicitly +commit it. Owner routes, under the normal authenticated server boundary: @@ -63,7 +70,7 @@ available through slash completion and `GET /commands`. ![Rewind boundary menu](screenshots/rewind-menu.png) -The review shows the actual proposed patches, current exclusions, retained tool +The review shows the actual proposed patches, snapshot exclusions, retained tool statuses and external-effect warning before confirmation. ![Reviewed code and conversation rewind](screenshots/rewind-review.png) @@ -101,13 +108,11 @@ The screenshots are real terminal captures against a local scripted provider. The capture fixture writes the sample edit during a held Turn to exercise actual Git snapshots and preview rendering; it does not confirm the rewind. -This implements the CLI and TUI rewind surfaces of -[#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). Broader native -crash/restart/interrupt acceptance, complete historical exclusion reporting, the -private non-Git backend and recoverable restore journal remain unfinished. Path -rules/context, root structured output and JSONL input are also outstanding. The -retry-count specification reconciliation does not implement root structured output. - +This implements the CLI and TUI rewind surfaces and the initial file-copy backend +of [#93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). Managed recovery +resolution, journal reclamation, complete historical Git exclusions and broader +native acceptance remain open. Path rules/context, root structured output and +JSONL input are also outstanding. ## Recovery acceptance coverage @@ -123,6 +128,63 @@ A fresh TUI App separately verifies that clear preserves a new terminal's draft. An instrumented snapshot-port test loses native acknowledgement, reloads the runtime and checks that subtree stop reports retained unknown evidence, keeps admission closed and does not replay restoration. This is not a native -partial-replacement crash test. Process-kill cuts during file replacement, the -recoverable restore journal and complete historical exclusion reporting remain -required acceptance work alongside the non-Git backend. +partial-replacement crash test. The file-copy suite additionally kills a real +worker between native file replacements, then verifies retained preimages, an incomplete journal and refusal +to replay. Broader cut-point/metadata/platform acceptance and managed resolution +remain required. Git restore has not gained this new journal. + +## Non-Git storage and retained restore intents + +File-copy snapshots live under +`/snapshot/global//`: immutable BLAKE3 objects in `files`, +versioned Location-bound manifests in `manifests`, and native restore evidence in +`journals`. The Location identity binds the canonical directory, native device/ +inode and creation time. Replacing a directory or using a foreign snapshot cannot +transfer restore authority. Capture hashes bytes every time; matching size and +mtime never establish content identity. + +Capture respects `.gitignore`, `.cyberignore` and `snapshots.ignore`; configured +ignores cannot be undone by a project whitelist. Private owner roots and `.git`, +`.cyber` and `.claude` directories are excluded. Symlinks, hardlinks, read-only +files, non-plain ACL/extended metadata and unsupported objects are skipped. Snapshot exclusions are retained in each manifest so omitted +files are not falsely described as deletions. File modes appear in review. Existing +ownership is preserved; recreating a deleted file uses its recorded ordinary ownership/mode or refuses before publishing it. +Missing parents are created privately. Non-plain metadata is excluded until it can +be retained and recreated safely. + +`snapshots.max_file_bytes` defaults to 2 MiB (hard per-file bound 64 MiB), +`snapshots.fallback_max_bytes` to 200 MiB per Location, and +`snapshots.retention_days` to seven days. Limits account for retained logical +payloads, records and displaced preimages; filesystem allocation overhead differs. +A cap warning is emitted once per Location/condition in the running service; +conversation work and successful `exec` results continue. A fresh capture retries +availability after conditions change. Durable Session references protect staged +baselines and inactive branches. Unreferenced old manifests/objects can be pruned +only with a complete durable reference view. Every namespace containing journals +is conservatively retained; no quota or timeout discards unresolved evidence. + +Before file changes, restore durably publishes an intent and prepares private +stages. Replacements retain displaced inodes; progress and completion are flushed +independently. Admission and content/route identities are rechecked. A failure or +process loss leaves the journal and preimages intact and blocks later file-copy +capture/restore in that Location. It does not automatically roll back, retry or +acknowledge uncertain native effects. + +Inspect retained evidence through the normal authenticated owner API: + +```sh +cyber api GET /sessions//rewind/recovery +``` + +`GET /api/v1/sessions/{id}/rewind/recovery` and SDK +`client.session.snapshotRecovery(sessionID)` list the selected Session's +Location journals. Inspection is bounded and read-only, remains available while a +native writer is paused, and does not create an absent namespace or start +inference. `filesystem_complete` means only that the worker flushed its final +filesystem record; it does not acknowledge the Session/native receipt. `applied` +contains durable checkpoints; a missing entry is not proof of no effect. + +Managed keep/rollback resolution and acknowledgement/reclamation of retained +journals are not implemented yet. Do not edit records or remove evidence to force +a retry. This increment preserves recovery material and provides inspection; it +does not claim complete operator recovery or arbitrary external-writer atomicity. diff --git a/docs/screenshots/rewind-menu.png b/docs/screenshots/rewind-menu.png index dc9ba9cd..6881ed9a 100644 Binary files a/docs/screenshots/rewind-menu.png and b/docs/screenshots/rewind-menu.png differ diff --git a/docs/screenshots/rewind-review.png b/docs/screenshots/rewind-review.png index aabf0cb5..f1149d4e 100644 Binary files a/docs/screenshots/rewind-review.png and b/docs/screenshots/rewind-review.png differ diff --git a/openspec/changes/complete-daily-coding-surfaces/design.md b/openspec/changes/complete-daily-coding-surfaces/design.md index 89223582..63da752a 100644 --- a/openspec/changes/complete-daily-coding-surfaces/design.md +++ b/openspec/changes/complete-daily-coding-surfaces/design.md @@ -96,3 +96,38 @@ attachment chips to preserve those inputs. Historical prompt restoration copies text, without manufacturing media attachment authority. After a restart without a retained original draft, clear preserves the current composer. No uncertain operation is replayed automatically. + + +### Non-Git file-copy implementation increment + +The first production backend supports admitted Linux/macOS filesystems and keeps +Git selection and disabled snapshots compatible. Objects/manifests/journals use +`/snapshot/global//`, adding a Location namespace to the +canonical fallback layout because non-Git projects share the `global` project ID. +The identity hashes canonical path plus native device/inode/creation time, avoiding +mtime/size authority and inode-reuse adoption. Bounded descriptor-relative walks +never follow directory symlinks; configured ignores and private roots take priority. +Mode changes are explicit in review. Unsupported native metadata, directory +identity or cross-filesystem replacement refuses visibly; Windows fallback remains +unavailable rather than using unchecked writes. + +Versioned manifests retain excluded classes. Current/target/recorded exclusions +enter the reviewed fingerprint; excluded paths are unknown, not deletions. A bounded +private store accounts for logical payloads, records and displaced preimages. +Native restore prepares stages and durably records its intent before namespace +mutation; admission, contents and directory routes are refreshed per file. +Unknown/interrupted intents block reuse and keep actual displaced preimages. +`GET /sessions/{id}/rewind/recovery` is read-only owner inspection, uses durable +Session Location metadata without waiting on a held live projection, and never +settles native evidence. Its `filesystem_complete` flag describes the filesystem +record only. Live inspection takes no mutation lease and reads atomically published +records; missing progress never implies no effect. + +GC uses the application's bounded complete durable `copy:` reference set. It +preserves all referenced manifests, including inactive branches and stages, and +refuses pruning without that view. Any namespace with journals is retained until +a future explicit managed-resolution protocol can prove acknowledgement; this +increment does not invent a keep/rollback policy or clear native fences. That +resolution and journal reclamation, Git restore journaling, full native acceptance +and complete historical Git exclusions remain open under tasks 2.4/3.2–3.4. +Snapshot warnings are optional capture diagnostics, not failed conversation results. diff --git a/openspec/changes/complete-daily-coding-surfaces/implementation.md b/openspec/changes/complete-daily-coding-surfaces/implementation.md index 55e480e2..66ea14a8 100644 --- a/openspec/changes/complete-daily-coding-surfaces/implementation.md +++ b/openspec/changes/complete-daily-coding-surfaces/implementation.md @@ -181,3 +181,107 @@ this increment; two long test scenarios were split into named recovery phases rather than exempting them from Clippy's complexity limit. Linux/Windows execution remains pending CI. Existing native C deprecation and linker unwind warnings were observed during local builds; successful checks do not substitute for platform CI. + + +## Private non-Git snapshots and retained restore intents + +This increment starts from merged #109 (`3dac1557`). The snapshot port selects +private file-copy storage for admitted Linux/macOS Locations outside Git, retaining +the existing Git backend. Task 3.1 is implemented. Tasks 3.2–3.4 and acceptance 2.4 +remain open: managed keep/rollback resolution, native receipt acknowledgement, +journal reclamation and broader native crash/platform acceptance are unfinished. +The goal and issue #93 are not complete; #92 and owner-closed #94 keep their scope. + +Objects are BLAKE3-addressed; manifests bind the canonical Location plus native +device/inode/creation identity. Every capture hashes bytes, including same-size/ +same-mtime changes. Descriptor-relative no-follow traversal applies ignores, +private roots, per-file limits and unsupported metadata exclusions. An omitted +historical/current path is not a deletion. Review includes retained file-copy +exclusions and ordinary mode changes. Existing ownership is preserved; recreating +a deleted file prepares recorded uid/gid/mode in private storage or refuses before +publication. Missing parents are private. ACL/xattr/platform-flag/read-only files +are excluded until their metadata can be retained and recreated safely. + +Restore checks the reviewed tree and admission under owned mutation/storage locks, +publishes a durable intent, prepares stages and uses native exchange/no-replace +operations. Actual displaced inodes are retained, with independently flushed +progress and final filesystem completion. Locks remain owned by the blocking +worker after async request cancellation. An incomplete/malformed journal refuses +further file-copy capture/restore; no automatic retry or rollback is inferred. +The read-only authenticated owner recovery route remains usable while the native +writer is paused, does not create absent namespaces, and never acknowledges a +Session/native receipt. `filesystem_complete` is deliberately limited to worker +filesystem completion; an absent `applied` entry does not establish no effect. + +The default per-Location 200 MiB logical cap includes retained stages/preimages. +Optional snapshot warnings do not fail a successful CLI conversation or stop the +TUI Session. A complete application durable-reference view protects event-held +roots, including stages and inactive branches. Failed/missing reference collection +refuses pruning; every namespace containing a journal remains retained. Aged +unpublished temporary objects/manifests can be reclaimed when no journal exists. + +The substantive code-review pass resolved: treating omitted files as deletions; +configured ignores overridden by project whitelists; blocking live inspection on +a writer-held lock; missing displaced-preimage quota reservation; privacy gaps +from inherited macOS ACL grants; and recreating files with weaker metadata. +Regressions exercise these corrections. Snapshot I/O remains behind the existing +server port; the application supplies private scopes and durable references, while +CLI/TUI only classify optional diagnostics and render existing reviewed responses. +No external reviewer or external model provider was used. + +Local verification and CI results are recorded in the PR. Platform constraints are +explicit: stable creation identity, admitted metadata/native operations and +same-filesystem private restore storage are required; Windows file-copy writes +remain unavailable with an actionable reason and conversation-only fallback. +Git restore has not gained the new journal. The native CI matrix runs snapshot +proofs on Linux x64/arm64 and macOS Intel/ARM; Windows CI selects the explicit +unsupported-file-copy test. Broader cut points, metadata/platform combinations and +managed resolution remain coverage gaps rather than completed acceptance. + + +Local macOS validation for this increment: 17 file-copy tests and 12 existing Git +snapshot tests passed, including whole-plan conflict refusal and preimage-space +reservation before mutation. All 11 real CLI branch/recovery scenarios passed with +serialized fixtures; the initial parallel run stalled during first exec startup +before prompt admission and was terminated, not counted as a pass. Ten focused TUI +rewind tests and four server ownership/recovery tests passed. The current OpenAPI +artifact and generated SDK consistency checks passed; SDK typecheck and 128 tests +passed with ten configured external skips. Real Git-backed terminal menu/review +screenshots were recaptured and visually checked. Strict OpenSpec 1.13.2 passed all +114 items; spec lint reported zero errors and 25 warnings. Formatting and diff +checks passed. Native C deprecation and linker unwind warnings were observed; +Linux/Windows and additional macOS architectures require the PR's platform CI. + +Workspace all-target Clippy with warnings denied also passed on the final code. + + +### PR #110 CI correction and merged-history integration + +The initial PR head `6a8a33b8` failed its Linux x64/ARM snapshot proofs and the +Linux Rust suite because directory sync called fsync on cap-std O_PATH handles. +Sync now reopens `.` relative to the pinned descriptor with read/directory/no-follow +flags, retaining descriptor authority while obtaining an fsync-capable handle. +The new Linux regression explicitly constructs an O_PATH directory and syncs it. +The macOS ARM proof also reported a storage-lock acquisition failure; acquisition +now retries only bounded short contention/interruption before any restore starts, +keeps a live owner exclusive and reports other lock errors distinctly. Regression +coverage checks both lock handoff and refusal while the owner remains live. +No source mutation is retried by this acquisition change. + +Merged current main `913fe1b0`, including #111's Ctrl+R history search, into the +existing PR branch without conflicts. The roadmap's 1026-requirement inventory and +history-search feature/docs/specs are preserved. A combined regression verifies +that a snapshot warning preserves the search and draft, cancelling search does +not arm rewind, and subsequent Esc-Esc opens rewind without replacing the draft. +Managed recovery resolution and journal reclamation remain outside this correction. + + +After integration, local macOS checks passed: two storage regressions, 17 file-copy +cases, 12 Git snapshot cases, 11 rewind-filtered TUI cases, nine history-search- +filtered TUI cases (one overlap), and three real non-Git CLI cases. Ten additional +parallel file-copy suite repetitions passed. Workspace all-target Clippy with +warnings denied, formatting/diff checks, generated SDK consistency and requirement +inventory checks passed. Strict OpenSpec passed 115 items after #111; lint reported +zero errors and 25 warnings. Linux O_PATH execution cannot run on this macOS host; +the corrected head requires fresh Linux x64/ARM and macOS platform proof CI. No +old-head green checks are used to accept this integrated revision. diff --git a/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md b/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md index 35f45e36..b7e02f8c 100644 --- a/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md +++ b/openspec/changes/complete-daily-coding-surfaces/specs/snapshots-checkpoints/spec.md @@ -49,3 +49,31 @@ #### Scenario: Interrupted restore - **WHEN** a file-copy restore loses its owner after a possible partial replacement - **THEN** recovery retains the journal and backup evidence and does not report success or blindly repeat the mutation + + +#### Scenario: Inspect retained restore evidence +- **WHEN** an authenticated owner inspects recovery journals for a Session's Location +- **THEN** the read-only bounded response reports filesystem completion and durable progress without restoring, acknowledging native receipts, creating an absent namespace or starting inference +- **AND** inspection remains available while a native writer is paused + +#### Scenario: Excluded file is not a deletion +- **WHEN** an otherwise recorded non-Git path is excluded by a later capture +- **THEN** its absence does not authorize deletion or synthesize a deletion diff, and retained target/recorded exclusions appear in a rewind review + +#### Scenario: Snapshot cap does not fail conversation +- **WHEN** the fallback cap makes file-copy capture unavailable +- **THEN** a one-time diagnostic leaves conversation execution and conversation-only rewind available, and successful exec exits remain successful + +#### Scenario: Recreate an admitted deleted file +- **WHEN** a file-copy restore recreates a deleted file and its missing parents +- **THEN** it prepares the recorded ordinary ownership/mode before publishing the file, creates missing parents privately, and refuses unsupported metadata rather than widening access +- **AND** captures disclose exclusions for ACLs, extended attributes, platform flags or other metadata that cannot be recreated safely + +## MODIFIED Requirements + +### Requirement: Non-git fallback +(P1) In non-git Locations the system SHALL snapshot by copying changed files with verified BLAKE3 identity into private `/snapshot///files/` storage, up to `snapshots.fallback_max_bytes` (default 200 MiB per Location). Non-git projects SHALL use `global` with an isolated Location namespace. When the limit is exceeded it SHALL disable code rewind for that Location with a one-time warning while conversation work continues. Unsupported native storage SHALL be unavailable explicitly rather than weakening restore guarantees. + +#### Scenario: Non-git project +- **WHEN** a Session runs in an admitted folder without git +- **THEN** edits remain rewindable through private Location-bound file-copy snapshots diff --git a/openspec/changes/complete-daily-coding-surfaces/tasks.md b/openspec/changes/complete-daily-coding-surfaces/tasks.md index 5a182167..1f69da19 100644 --- a/openspec/changes/complete-daily-coding-surfaces/tasks.md +++ b/openspec/changes/complete-daily-coding-surfaces/tasks.md @@ -16,7 +16,7 @@ Task 2.4 evidence now includes real service kill/reload/restage/clear, disabled ## 3. Non-git snapshots -- [ ] 3.1 Add private content-addressed BLAKE3 objects and Location-bound manifests behind the snapshot interface, including ignore/size/metadata reporting and same-size/same-mtime verification. +- [x] 3.1 Add private content-addressed BLAKE3 objects and Location-bound manifests behind the snapshot interface, including ignore/size/metadata reporting and same-size/same-mtime verification. - [ ] 3.2 Implement conflict-aware owned restore and recoverable backups/journal, preserving Git behavior, protected paths, symlink/hardlink safety and interruption evidence. - [ ] 3.3 Integrate 200 MiB cap, visible unavailable/overflow state, reference-aware retention and staged/branch/recovery protections. - [ ] 3.4 Test real non-git editing/restoration, limits, deletes/renames, unsupported files, concurrent Session/editor changes, crashes and native filesystem privacy. diff --git a/sdk/openapi.json b/sdk/openapi.json index be638e07..3822f9b5 100644 --- a/sdk/openapi.json +++ b/sdk/openapi.json @@ -7015,6 +7015,52 @@ "data" ] }, + "Data_Array_of_SnapshotRecovery": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotRecovery" + } + } + }, + "required": [ + "data" + ] + }, + "SnapshotRecovery": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "filesystem_complete": { + "type": "boolean", + "description": "The filesystem worker flushed its final record; not Session/native receipt acknowledgement." + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "applied": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Durably observed file checkpoints; absence does not prove that no effect occurred." + } + }, + "required": [ + "id", + "filesystem_complete", + "paths", + "applied" + ], + "description": "Retained filesystem evidence; completion does not acknowledge a Session/native receipt." + }, "RewindSelection": { "type": "object", "additionalProperties": false, @@ -23770,6 +23816,47 @@ } } }, + "/api/v1/sessions/{sessionID}/rewind/recovery": { + "get": { + "operationId": "v1.session.snapshotRecovery", + "summary": "Inspect retained filesystem restore journals without acknowledging or replaying native effects", + "tags": [ + "session" + ], + "parameters": [ + { + "name": "sessionID", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Data_Array_of_SnapshotRecovery" + } + } + } + }, + "default": { + "description": "Tagged error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + } + } + }, "/api/v1/sessions/{sessionID}/rewind/preview": { "post": { "operationId": "v1.session.rewindPreview", diff --git a/sdk/typescript/src/generated/operations.ts b/sdk/typescript/src/generated/operations.ts index b738c314..9f0ddf77 100644 --- a/sdk/typescript/src/generated/operations.ts +++ b/sdk/typescript/src/generated/operations.ts @@ -1541,6 +1541,12 @@ export interface Operations { body: undefined; response: S.Data_Nullable_Goal; }; + "v1.session.snapshotRecovery": { + path: { sessionID: string }; + query: Record; + body: undefined; + response: S.SnapshotRecovery[]; + }; "v1.session.startDelegation": { path: { sessionID: string; requestID: string }; query: Record; @@ -4590,6 +4596,18 @@ export const operations = { unwrap: false, stream: false, }, + "v1.session.snapshotRecovery": { + tag: "session", + name: "snapshotRecovery", + method: "GET", + path: "/api/v1/sessions/{sessionID}/rewind/recovery", + pathParams: ["sessionID"], + query: [], + body: "none", + located: false, + unwrap: true, + stream: false, + }, "v1.session.startDelegation": { tag: "session", name: "startDelegation", @@ -5974,6 +5992,9 @@ export function createGroups(caller: Caller) { /** Clear the current Goal and activate the next queued one; null when the queue was empty (`POST /api/v1/sessions/{sessionID}/goals/skip`) */ skipGoal: (sessionID: string, options?: RequestOptions): Promise => caller.call("v1.session.skipGoal", { path: { sessionID } }, options), + /** Inspect retained filesystem restore journals without acknowledging or replaying native effects (`GET /api/v1/sessions/{sessionID}/rewind/recovery`) */ + snapshotRecovery: (sessionID: string, options?: RequestOptions): Promise => + caller.call("v1.session.snapshotRecovery", { path: { sessionID } }, options), /** Durably reserve a named user delegation request (`POST /api/v1/sessions/{sessionID}/delegations/{requestID}`) */ startDelegation: (sessionID: string, requestID: string, body: Operations["v1.session.startDelegation"]["body"], options?: RequestOptions): Promise => caller.call("v1.session.startDelegation", { path: { sessionID, requestID }, body }, options), diff --git a/sdk/typescript/src/generated/types.ts b/sdk/typescript/src/generated/types.ts index 88fcd4db..f6b4e814 100644 --- a/sdk/typescript/src/generated/types.ts +++ b/sdk/typescript/src/generated/types.ts @@ -902,6 +902,10 @@ export type Data_Array_of_RemoteReach = { data: RemoteReach[]; }; +export type Data_Array_of_SnapshotRecovery = { + data: SnapshotRecovery[]; +}; + export type Data_Array_of_Task = { data: Task[]; }; @@ -4658,6 +4662,16 @@ export type SkillInfo = { /** The scope a skill was discovered in. */ export type SkillOrigin = "builtin" | "user" | "project" | "config" | "plugin"; +/** Retained filesystem evidence; completion does not acknowledge a Session/native receipt. */ +export type SnapshotRecovery = { + id: string; + /** The filesystem worker flushed its final record; not Session/native receipt acknowledgement. */ + filesystem_complete: boolean; + paths: string[]; + /** Durably observed file checkpoints; absence does not prove that no effect occurred. */ + applied: string[]; +}; + export type SolveCandidateEvidence = { candidate_id: string; label?: string | null;