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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ For independent patch attempts, host-recorded tests and a separate read-only jud

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 also available through the owner API/SDK. 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).
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).

![Binary hex workspace with a shared hex/ASCII cursor and little-endian byte inspector](docs/screenshots/binary-hex.png)

Expand Down
8 changes: 8 additions & 0 deletions crates/cyber-app/tests/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1672,6 +1672,14 @@ async fn binary_closed_dirty_draft_is_recoverable_and_prevents_session_deletion(
.0,
200
);
let (code, refused) = f
.send(
"POST",
&format!("{path}/recover"),
Some(json!({"session_id":session,"expected_root":"/wrong-project"})),
)
.await;
assert_eq!(code, 403, "{refused}");
let (code, recovered) = f
.send(
"POST",
Expand Down
63 changes: 62 additions & 1 deletion crates/cyber-cli/src/commands/hex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,25 @@ pub struct HexArgs {
/// Open an existing Session without rebinding its Location.
#[arg(long)]
pub session: Option<String>,
/// Inspect bytes without entering an edit draft.
/// Inspect bytes without enabling draft edits.
#[arg(long)]
pub read_only: bool,
/// Reopen a kept manual draft in its original --session.
#[arg(long, requires="session", value_parser=document_id)]
pub recover: Option<String>,
}
fn document_id(value: &str) -> Result<String, String> {
if value.starts_with("bin_")
&& value.len() > 4
&& value.len() <= 100
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
Ok(value.into())
} else {
Err("expected a retained bin_ document identity".into())
}
}
fn offset(value: &str) -> Result<u64, String> {
match value.strip_prefix("0x") {
Expand Down Expand Up @@ -75,13 +91,58 @@ pub fn run(args: HexArgs, mut launch: LaunchArgs, global: &GlobalArgs) -> Result
root: root.display().to_string(),
offset: args.offset,
read_only: args.read_only,
recover: args.recover,
});
super::tui::run(&launch, global, None)
}

#[cfg(test)]
mod tests {
use clap::Parser;
#[test]
fn kept_draft_recovery_requires_the_original_session_and_a_safe_document_identity() {
let parsed = crate::cli::Cli::try_parse_from([
"cyber",
"hex",
"fixture.bin",
"--session",
"ses_one",
"--recover",
"bin_kept",
"--read-only",
])
.unwrap();
let Some(crate::cli::Command::Hex(args)) = parsed.command else {
panic!("hex command")
};
assert_eq!(args.recover.as_deref(), Some("bin_kept"));
assert!(args.read_only);
assert!(
crate::cli::Cli::try_parse_from([
"cyber",
"hex",
"fixture.bin",
"--recover",
"bin_kept"
])
.is_err()
);
for id in ["../bin_kept", "bin_", "bin_one/bytes", "other"] {
assert!(
crate::cli::Cli::try_parse_from([
"cyber",
"hex",
"fixture.bin",
"--session",
"ses_one",
"--recover",
id
])
.is_err()
);
}
}

#[test]
fn hex_entry_parses_exact_offsets_and_rejects_invalid_sizes_before_admission() {
let cli = crate::cli::Cli::try_parse_from([
Expand Down
12 changes: 12 additions & 0 deletions crates/cyber-server/src/http/binary_files/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub struct RecoverRequest {
pub session_id: String,
#[serde(default)]
pub read_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_root: Option<String>,
}
#[derive(Serialize, JsonSchema)]
#[schemars(rename = "BinaryRecoveryEntry")]
Expand Down Expand Up @@ -108,6 +110,16 @@ pub(super) async fn recover(
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let (turn, root, _) = files::scope(&state, &parts, Some(&input.session_id)).await?;
if input
.expected_root
.as_ref()
.is_some_and(|expected| expected != &root.display().to_string())
{
return Err(
ApiError::forbidden("The selected Session belongs to another project")
.with_reason("binary_scope"),
);
}
let owner = owner(&turn, root.clone()).map_err(refused)?;
let storage = state
.runtime
Expand Down
66 changes: 59 additions & 7 deletions crates/cyber-tui/src/hex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum Request {
path: String,
expected_root: Option<String>,
read_only: bool,
recover: Option<String>,
offset: u64,
generation: u64,
},
Expand All @@ -56,6 +57,7 @@ pub struct Loaded {
pub document: Document,
pub offset: u64,
pub bytes: Vec<u8>,
pub recovered: Option<Value>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
Expand Down Expand Up @@ -161,6 +163,14 @@ impl Workspace {
self.loading = false;
match result {
Ok(loaded) => {
if let Some(status) = loaded.recovered
&& let Err(error) = self.edit.recovered(status, &loaded.document)
{
self.document = Some(loaded.document);
self.bytes.clear();
self.error = Some(error);
return;
}
self.document = Some(loaded.document);
self.page_offset = loaded.offset;
self.bytes = loaded.bytes;
Expand Down Expand Up @@ -204,6 +214,22 @@ pub fn goto(input: &str, cursor: u64, length: u64) -> Result<u64, &'static str>
}

impl App {
pub fn recover_hex_at(
&mut self,
id: &str,
path: &str,
root: Option<String>,
offset: u64,
read_only: bool,
) -> Vec<Action> {
let mut actions = self.open_hex_at(path, root, offset, read_only);
for action in &mut actions {
if let Action::Hex(Request::Open { recover, .. }) = action {
*recover = Some(id.into());
}
}
actions
}
pub fn open_hex(&mut self, path: &str) -> Vec<Action> {
self.open_hex_at(path, None, 0, false)
}
Expand Down Expand Up @@ -239,6 +265,7 @@ impl App {
path: path.into(),
expected_root,
read_only,
recover: None,
offset,
generation: self.hex.generation,
}));
Expand Down Expand Up @@ -586,6 +613,7 @@ async fn page(
document,
offset,
bytes,
recovered: None,
})
}

Expand Down Expand Up @@ -691,21 +719,37 @@ pub async fn perform(
path,
expected_root,
read_only,
recover,
offset,
generation,
} => {
let result = async {
if recover.as_ref().is_some_and(|id| {
!id.starts_with("bin_") || id.len() > 100 || id.len() <= 4
|| !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}) {
return Err("Invalid retained binary document identity".into());
}
let route = recover.as_ref().map_or_else(
|| "/files/binary/documents".into(),
|id| format!("/files/binary/documents/{id}/recover"),
);
let body = if recover.is_some() {
json!({"session_id":session.id,"expected_root":expected_root,"read_only":read_only})
} else {
json!({"session_id":session.id,"path":path,"expected_root":expected_root,"read_only":read_only})
};
let value = client
.at(&session.directory)
.post(
"/files/binary/documents",
json!({"session_id":session.id,"path":path,"expected_root":expected_root,"read_only":read_only}),
)
.post(&route, body)
.await
.map_err(|e| e.to_string())?;
let data = &value["data"];
let document = Document {
id: field(data, "document_id")?,
id: match &recover {
Some(id) => id.clone(),
None => field(data, "document_id")?,
},
path: field(data, "path")?,
revision: field(data, "revision")?,
source: field(data,"source")?,
Expand All @@ -714,10 +758,17 @@ pub async fn perform(
.map_err(|_| "Invalid byte length")?,
};
let id = document.id.clone();
let result = if offset > document.length {
let result = if recover.is_some() && (document.path != path || document.source != "draft") {
Err("Recovered draft does not match the requested file".into())
} else if offset > document.length {
Err("Offset is past EOF".into())
} else {
page(client, session, document, offset / 4096 * 4096).await
page(client, session, document, offset / 4096 * 4096).await.map(|mut loaded| {
if recover.is_some() {
loaded.recovered = Some(data.clone());
}
loaded
})
};
if result.is_err() {
let _ = client
Expand Down Expand Up @@ -855,6 +906,7 @@ mod ownership_tests {
},
offset: 0,
bytes: vec![0, 255],
recovered: None,
}),
};
assert!(apply(&mut app, message).is_empty());
Expand Down
Loading
Loading