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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ 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 CLI 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. TUI rewind and the non-Git backend 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.

The [binary workspace](docs/binary-editor.md) provides paged hex/ASCII viewing, literal hex/UTF-8 search, exact goto, bookmarks, byte copying and an LE/BE inspector. Open it with `cyber hex <path>`, `/hex <path>`, or `x` on an explorer file, without starting inference. Session read permissions apply. On admitted Linux/macOS workspaces, F2 prepares private staged edits with undo/redo; Ctrl+S reviews the exact byte changes, then explicitly saves. Reviewed Restore Last Save and retained draft recovery are available through the owner API/SDK; `cyber hex <path> --session <id> --recover <document>` reopens a kept manual draft in view mode with fresh F2 edit admission. Authorized Sessions can review shared saved history, and Session diffs show saved binary references independently of Git. The model-facing `binary` tool supports bounded inspect/read/search and exact reviewed patches in isolated invocation drafts. Separate owner routes and SDK helpers discover and reconcile model-operation receipts. Checkpoint/retention/rewind adapters and remaining acceptance work are tracked in [#94](https://github.com/CyberdyneCorp/cybercode-cli/issues/94).
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ 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. The [first rewind increment](docs/rewind.md) adds typed CLI previews and explicit confirmation, fresh snapshot/Session revalidation and branch preservation. TUI rewind/undo/redo, 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.

**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.

**Untrusted-content defense (`untrusted-content`, `permissions-modes`, `system-context`, P1): Status: implemented** (change [`add-untrusted-content-defense`](openspec/changes/add-untrusted-content-defense/proposal.md)). It carries the `workspace-trust` provenance rule through every tool that returns third-party content. `webfetch`, `websearch`, MCP tool results, browser pages and MCP resources are returned in one escape-safe `<untrusted_content>` frame that replaces the MCP-resource frame. Hidden characters become visible markers, and a deterministic detector flags instruction-like text with a notice in the TUI, `exec` and the dashboard, without dropping content. After a flagged result, risky actions (network egress, writes outside the Location, classifier-allowed commands, secret reads) ask until the next user prompt, or are denied with no user attached; `bypass` shows the notice or refuses when configured. The base prompts cover the frame (version 3), and a labeled fixture set gates recall and false positives, with scripted end-to-end scenarios for secret reads, permission widening and provider redirection. The TUI and dashboard state each notice's consequence from the Location's effective `taint`/`bypass` settings (task 5.3). What remains: acceptance by hand on the real binary.
Expand Down
1 change: 1 addition & 0 deletions crates/cyber-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub mod plugins;
pub mod pr;
pub mod providers;
pub mod review;
mod rewind;
pub mod sandbox;
pub mod serve;
pub mod sessions;
Expand Down
140 changes: 140 additions & 0 deletions crates/cyber-cli/src/commands/rewind.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Owner-reviewed rewind. The default is a non-mutating preview; applying requires
//! the exact fingerprint from a prior review, including in non-interactive scripts.
use clap::ValueEnum;
use cyber_client::Client;
use serde_json::json;

use super::sessions::{api, clean, encode, resolve};
use crate::{cli::GlobalArgs, error::CliError, output};

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Target {
Code,
Conversation,
Both,
}
impl Target {
fn name(self) -> &'static str {
match self {
Self::Code => "code",
Self::Conversation => "conversation",
Self::Both => "both",
}
}
}

pub fn fingerprint(value: &str) -> Result<String, String> {
if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
Ok(value.to_ascii_lowercase())
} else {
Err("expected the 64-character confirmation fingerprint from a rewind preview".into())
}
}

pub async fn run(
client: &Client,
session: &str,
message: &str,
target: Target,
confirmation: Option<String>,
global: &GlobalArgs,
) -> Result<(), CliError> {
let found = resolve(client, session).await?;
let id = found["id"]
.as_str()
.ok_or_else(|| CliError::runtime("Missing Session identity"))?;
let directory = found["directory"]
.as_str()
.ok_or_else(|| CliError::runtime("Missing Session Location"))?;
let mut body =
json!({"message_id":message,"target":target.name(),"expected_directory":directory});
let applying = confirmation.is_some();
if let Some(confirmation) = confirmation {
body["confirmation"] = json!(confirmation);
}
let route = if applying { "confirm" } else { "preview" };
let reply = client
.post(&format!("/sessions/{}/rewind/{route}", encode(id)), body)
.await
.map_err(api)?;
let data = &reply["data"];
if output::is_json(global.format) {
return output::json(data);
}
if applying {
println!(
"rewound {} before {} ({})",
clean(id),
clean(message),
target.name()
);
if matches!(target, Target::Code) {
println!("The conversation is unchanged.");
} else {
println!("Later messages remain on an inactive branch through sessions tree/checkout.");
}
return Ok(());
}
println!(
"Rewind preview: {} before {} ({})",
clean(id),
clean(message),
target.name()
);
println!("Location: {}", clean(directory));
if let Some(files) = data["code"]["files"].as_array() {
for file in files {
println!(
"{} {}",
clean(file["status"].as_str().unwrap_or("changed")),
clean(file["file"].as_str().unwrap_or(""))
);
for line in file["patch"].as_str().unwrap_or("").lines() {
println!("{}", clean(line));
}
}
for path in data["code"]["skipped"].as_array().into_iter().flatten() {
println!(
"excluded from current snapshot: {}",
clean(path.as_str().unwrap_or(""))
);
}
}
println!(
"{}",
data["warning"]
.as_str()
.unwrap_or("External effects are not undone.")
);
for effect in data["effects"].as_array().into_iter().flatten() {
println!(
"retained tool effect: {} {} ({})",
clean(effect["call_id"].as_str().unwrap_or("")),
clean(effect["tool"].as_str().unwrap_or("")),
effect["status"]
);
}
println!(
"No files or conversation path changed. Review this preview, then repeat with --confirm {}",
data["confirmation"].as_str().unwrap_or("")
);
Ok(())
}

#[cfg(test)]
mod tests {
use clap::Parser;
#[test]
fn invalid_targets_and_unreviewed_confirmations_fail_before_startup() {
for tail in [
vec!["--target", "everything"],
vec!["--confirm", "yes"],
vec!["--confirm", &"x".repeat(64)],
] {
let mut args = vec!["cyber", "sessions", "rewind", "ses_one", "--to", "msg_one"];
args.extend(tail);
assert!(crate::cli::Cli::try_parse_from(args).is_err());
}
assert_eq!(super::fingerprint(&"A".repeat(64)).unwrap(), "a".repeat(64));
}
}
21 changes: 20 additions & 1 deletion crates/cyber-cli/src/commands/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ pub enum SessionsCmd {
#[arg(long)]
exact: bool,
},
/// Preview a rewind before a user message; apply only with its reviewed fingerprint.
Rewind {
/// ID, exact title or unique ID prefix.
session: String,
/// Exact active user-message ID; the boundary itself is excluded on conversation rewind.
#[arg(long)]
to: String,
#[arg(long, value_enum, default_value = "both")]
target: super::rewind::Target,
/// Apply the exact previously reviewed preview; changed context requires a new preview.
#[arg(long, value_parser = super::rewind::fingerprint)]
confirm: Option<String>,
},
/// Set or remove a branch label.
Label {
/// ID, exact title or unique ID prefix.
Expand Down Expand Up @@ -106,7 +119,7 @@ pub enum SessionsCmd {
Other(Vec<String>),
}

const LATER: &[&str] = &["fork", "rewind", "repair-context", "share", "unshare"];
const LATER: &[&str] = &["fork", "repair-context", "share", "unshare"];

pub fn run(cmd: SessionsCmd, ctx: &Context, global: &GlobalArgs) -> Result<(), CliError> {
if let SessionsCmd::Other(args) = &cmd {
Expand Down Expand Up @@ -180,6 +193,12 @@ async fn dispatch(client: &Client, cmd: SessionsCmd, global: &GlobalArgs) -> Res
write_export(&document, format, output.as_deref())
}
SessionsCmd::Import { file } => import(client, &file, global).await,
SessionsCmd::Rewind {
session,
to,
target,
confirm,
} => super::rewind::run(client, &session, &to, target, confirm, global).await,
SessionsCmd::Other(_) => unreachable!("handled before connecting"),
branch => dispatch_branch(client, branch, global).await,
}
Expand Down
143 changes: 143 additions & 0 deletions crates/cyber-cli/tests/branches_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,146 @@ fn refuses_while_busy(env: &Env, model: &scripted_model::ScriptedModel, id: &str
assert!(gated.wait().unwrap().success());
assert_eq!(env.user_ids(id).len(), 2);
}

fn rewind_preview(env: &Env, session: &str, message: &str, target: &str) -> Value {
json(&env.cyber(&[
"sessions", "rewind", session, "--to", message, "--target", target, "--format", "json",
]))
}

fn confirm_rewind(
env: &Env,
session: &str,
message: &str,
target: &str,
preview: &Value,
) -> Output {
env.cyber(&[
"sessions",
"rewind",
session,
"--to",
message,
"--target",
target,
"--confirm",
preview["confirmation"].as_str().unwrap(),
"--format",
"json",
])
}

#[test]
fn rewind_preview_is_non_mutating_and_stale_files_refuse_before_restore() {
let model = scripted_model::ScriptedModel::start(reply);
let env = Env::new(&model);
let id = env.exec(None, "WRITE-A");
env.exec(Some(&id), "WRITE-B");
std::fs::write(env.root.join("repo/owner.txt"), "owner file\n").unwrap();
let users = env.user_ids(&id);
let preview = rewind_preview(&env, &id, &users[0], "code");
assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n"));
assert_eq!(env.file("b.txt").as_deref(), Some("beta\n"));
assert_eq!(env.user_ids(&id), users);
assert_eq!(preview["confirmation"].as_str().unwrap().len(), 64);
let files = preview["code"]["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(
files.iter().all(|file| file["file"] != "owner.txt"),
"unrelated owner files must not be shown as proposed deletions"
);
assert_owner_drift_refused(&env, &id, &users, &preview);
let fresh = rewind_preview(&env, &id, &users[0], "code");
json(&confirm_rewind(&env, &id, &users[0], "code", &fresh));
assert_eq!(env.file("a.txt"), None);
assert_eq!(env.file("b.txt"), None);
assert_eq!(env.file("owner.txt").as_deref(), Some("owner change\n"));
assert_eq!(
env.user_ids(&id),
users,
"code-only keeps the active conversation"
);
exits(&confirm_rewind(&env, &id, &users[0], "code", &fresh), 1);
}

fn assert_owner_drift_refused(env: &Env, id: &str, users: &[String], preview: &Value) {
std::fs::write(env.root.join("repo/owner.txt"), "owner change\n").unwrap();
let refused = exits(&confirm_rewind(env, id, &users[0], "code", preview), 1);
assert!(refused.contains("preview changed"), "{refused}");
assert_eq!(env.file("owner.txt").as_deref(), Some("owner change\n"));
assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n"));
assert_eq!(env.file("b.txt").as_deref(), Some("beta\n"));
assert_eq!(env.user_ids(id), users);
std::fs::write(env.root.join("repo/a.txt"), "conflicting owner edit\n").unwrap();
let refused = exits(
&env.cyber(&[
"sessions", "rewind", id, "--to", &users[0], "--target", "code",
]),
1,
);
assert!(refused.contains("RewindConflictError"), "{refused}");
assert_eq!(
env.file("a.txt").as_deref(),
Some("conflicting owner edit\n")
);
std::fs::write(env.root.join("repo/a.txt"), "alpha\n").unwrap();
}

#[test]
fn confirmed_combined_rewind_keeps_an_inactive_branch_and_rejects_cross_session_review() {
let model = scripted_model::ScriptedModel::start(reply);
let env = Env::new(&model);
let id = env.exec(None, "WRITE-A");
env.exec(Some(&id), "WRITE-B");
let users = env.user_ids(&id);
let preview = rewind_preview(&env, &id, &users[1], "both");
let other = env.exec(None, "another session");
let other_user = env.user_ids(&other)[0].clone();
exits(
&confirm_rewind(&env, &other, &other_user, "conversation", &preview),
1,
);
json(&confirm_rewind(&env, &id, &users[1], "both", &preview));
assert_eq!(env.file("a.txt").as_deref(), Some("alpha\n"));
assert_eq!(env.file("b.txt"), None);
assert_eq!(env.user_ids(&id), users[..1]);
let tree = env.tree(&id);
assert!(
tree.iter()
.any(|node| node["id"] == users[1] && node["active"] == false)
);
let exported = json(&env.cyber(&["sessions", "export", &id, "--all-branches"]));
assert!(
exported.to_string().contains(&users[1]),
"inactive messages remain exportable"
);
}

#[test]
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 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);
assert!(refused.contains("Snapshots are unavailable"), "{refused}");
let preview = rewind_preview(&env, &id, &first, "conversation");
assert_eq!(preview["code"], Value::Null);
env.exec(Some(&id), "second prompt");
let before = env.user_ids(&id);
let refused = exits(
&confirm_rewind(&env, &id, &first, "conversation", &preview),
1,
);
assert!(refused.contains("preview changed"), "{refused}");
assert_eq!(env.user_ids(&id), before);
let fresh = rewind_preview(&env, &id, &first, "conversation");
json(&confirm_rewind(&env, &id, &first, "conversation", &fresh));
assert!(env.user_ids(&id).is_empty());
assert!(!env.tree(&id).is_empty());
exits(
&confirm_rewind(&env, &id, &first, "conversation", &fresh),
2,
);
}
6 changes: 6 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,12 @@ pub fn operations(g: &mut SchemaGenerator) -> Vec<Op> {
)
.body::<RevertBody>(g)
.ok::<Data<RevertState>>(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),
op("post", "/sessions/{sessionID}/rewind/confirm", "v1.session.rewindConfirm", "Revalidate a reviewed fingerprint before owned rewind")
.body::<crate::runtime::RewindConfirmation>(g)
.ok::<Data<RevertState>>(g),
op(
"post",
"/sessions/{sessionID}/revert/stage",
Expand Down
Loading
Loading