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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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::))
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/non-git-snapshot-proof.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session> --to <message>`. Applying requires the reviewed `--confirm <fingerprint>`; 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 <session> --to <message>`. Applying requires the reviewed `--confirm <fingerprint>`; 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.

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 20 additions & 1 deletion crates/cyber-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,28 @@ impl App {
}))?;
let snapshots: Arc<dyn Snapshots> = if opts.snapshots {
let loader = Arc::clone(&config);
let snaps = cyber_snapshot::GitSnapshots::new(
let snapshot_store = store.clone();
let references: Arc<cyber_snapshot::ReferencesFn> = 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::<Result<Vec<_>,_>>()?;
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
Expand Down
10 changes: 10 additions & 0 deletions crates/cyber-cli/src/commands/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions crates/cyber-cli/tests/branches_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}
26 changes: 26 additions & 0 deletions crates/cyber-core/src/binary/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata, Error> {
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<Metadata, Error> {
let m = file.metadata().map_err(|_| Error::Io)?;
if !m.is_file() || m.nlink() != 1 || m.mode() & 0o7000 != 0 || m.permissions().readonly() {
Expand Down
2 changes: 2 additions & 0 deletions crates/cyber-server/src/http/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec<Op> {
)
.body::<RevertBody>(g)
.ok::<Data<RevertState>>(g),
op("get", "/sessions/{sessionID}/rewind/recovery", "v1.session.snapshotRecovery", "Inspect retained filesystem restore journals without acknowledging or replaying native effects")
.ok::<Data<Vec<crate::runtime::SnapshotRecovery>>>(g),
op("post", "/sessions/{sessionID}/rewind/preview", "v1.session.rewindPreview", "Review a rewind without changing files or the active conversation")
.body::<crate::runtime::RewindSelection>(g)
.ok::<Data<crate::runtime::RewindPreview>>(g),
Expand Down
Loading
Loading