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. Model binary operations, 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 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. 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
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.

**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. Model operations, checkpoint/retention/rewind adapters and remaining acceptance are still open; this is not full editor acceptance.
**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. 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
9 changes: 9 additions & 0 deletions crates/cyber-app/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ impl ToolHost for AppHost {
self.builtin.claim_location(info, creating, cancel)
}

fn claim_binary_location<'a>(
&'a self,
info: &'a cyber_server::runtime::SessionInfo,
document: &'a str,
cancel: tokio_util::sync::CancellationToken,
) -> BoxFuture<'a, Result<cyber_server::runtime::LocationLease, String>> {
self.builtin.claim_binary_location(info, document, cancel)
}

fn prepare_child_continuation<'a>(
&'a self,
parent: &'a cyber_server::runtime::SessionInfo,
Expand Down
29 changes: 29 additions & 0 deletions crates/cyber-app/src/host/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,35 @@ fn invocation(turn: &TurnContext) -> Invocation {
}
}

#[tokio::test]
async fn app_host_delegates_independent_binary_worker_checkout_ownership() {
let scratch = tempfile::tempdir().unwrap();
let app = application(scratch.path()).await;
let host = AppHost {
builtin: app.host.clone(),
remote: app.state.remote_tools.clone(),
peers: Arc::new(|_| Ok(None)),
peer_names: Arc::new(|| Ok(Vec::new())),
};
let info = app
.runtime
.create_session(cyber_server::runtime::CreateSession {
directory: scratch.path().canonicalize().unwrap().display().to_string(),
model: "test/main".into(),
..Default::default()
})
.await
.unwrap();
let lease = host
.claim_binary_location(&info, "bin_retained_worker", CancellationToken::new())
.await
.unwrap();
assert_eq!(lease.worktree_id, info.worktree_id);
let retained = lease.settle_retained().unwrap();
drop(retained);
app.runtime.shutdown().await;
}

#[tokio::test]
async fn agent_tool_restrictions_refuse_client_dispatch_without_sending_a_request() {
let scratch = tempfile::tempdir().unwrap();
Expand Down
73 changes: 73 additions & 0 deletions crates/cyber-core/src/binary/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,33 @@ fn stage_file(store: &Store, name: &str) -> Result<File, Error> {
Ok(file)
}
impl Editor {
/// Resume only the exact invocation/payload owner, authenticated by its small
/// header before opening any private command journal or base image.
pub fn model(
storage: &super::storage::StorageRoot,
id: &str,
owner: Owner,
document: &mut Document,
cancelled: impl Fn() -> bool,
) -> Result<Self, Error> {
if !matches!(owner.scope, DraftScope::Model { .. }) {
return Err(Error::Invalid);
}
let store = storage.open(id)?;
match store.directory().symlink_metadata("document.json") {
Ok(_) => {
if storage.resource(&owner, id)?.path() != document.resource().path() {
return Err(Error::Changed);
}
Self::resume(store, &owner)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Self::create(store, owner, document, cancelled)
}
Err(_) => Err(Error::Io),
}
}

pub fn create(
mut store: Store,
owner: Owner,
Expand Down Expand Up @@ -1288,6 +1315,52 @@ mod tests {
.unwrap();
}

#[test]
fn model_resume_binds_payload_and_never_opens_the_manual_draft() {
let (root, mut manual, path, mut owner) = setup();
patch(&mut manual, "private_manual_patch");
let private_revision = manual.draft().revision();
let storage =
super::super::storage::StorageRoot::prepare(&root.path().join("data")).unwrap();
owner.scope = DraftScope::Model {
operation: "ses_one:call_one:payload_one".into(),
};
let mut document = Document::open(&owner.root, "bytes").unwrap();
assert!(
Editor::model(&storage, "bin_one", owner.clone(), &mut document, || false).is_err()
);
let mut model = Editor::model(&storage, "bin_model", owner.clone(), &mut document, || {
false
})
.unwrap();
assert_eq!(
model
.draft()
.page(&model.draft().revision(), Offset(0), 8)
.unwrap()
.bytes,
b"original"
);
patch(&mut model, "patch");
let prepared = ready(&mut model, "save");
let receipt = model.commit_save(*prepared).unwrap();
drop(model);
let mut source = Document::open(&owner.root, "bytes").unwrap();
let resumed =
Editor::model(&storage, "bin_model", owner.clone(), &mut source, || false).unwrap();
assert_eq!(
resumed.lookup_operation("save").unwrap().unwrap().revision,
receipt.revision
);
drop(resumed);
owner.scope = DraftScope::Model {
operation: "ses_one:call_one:changed_payload".into(),
};
assert!(Editor::model(&storage, "bin_model", owner, &mut source, || false).is_err());
assert_eq!(manual.draft().revision(), private_revision);
assert_eq!(std::fs::read(path).unwrap(), [0, 255, 128, 27]);
}

#[test]
fn equivalent_patch_encodings_reuse_the_durable_receipt() {
let (_root, mut editor, _path, _owner) = setup();
Expand Down
28 changes: 28 additions & 0 deletions crates/cyber-core/src/binary/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,34 @@ impl Document {
Ok(())
}

/// Whole-file evidence for an editable-sized live source. This does not
/// prepare a draft or claim an immutable revision. Bytes stream in 64 KiB.
pub fn fingerprint(&mut self, cancelled: impl Fn() -> bool) -> Result<String, Error> {
if self.length > MAX_EDIT_SIZE {
return Err(Error::Limit);
}
self.validate()?;
let mut hash = Sha256::new();
let mut at = 0;
while at < self.length {
if cancelled() {
return Err(Error::Cancelled);
}
let count = (self.length - at).min(DEFAULT_PAGE as u64) as usize;
let bytes = self.read_at(at, count)?;
if bytes.len() != count {
return Err(Error::Changed);
}
hash.update(bytes);
at += count as u64;
}
if cancelled() {
return Err(Error::Cancelled);
}
self.validate()?;
Ok(format!("{:x}", hash.finalize()))
}

fn check_revision(&self, revision: &str) -> Result<(), Error> {
if revision != self.observed {
return Err(Error::Changed);
Expand Down
14 changes: 14 additions & 0 deletions crates/cyber-core/src/binary/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,17 @@ fn a_zero_length_virtual_file_cannot_return_bytes_beyond_its_observed_range() {
assert!(page.bytes.is_empty());
assert!(page.eof);
}

#[test]
fn live_fingerprints_stream_exact_bytes_and_refuse_cancellation_or_changed_sources() {
use sha2::{Digest, Sha256};
let bytes: Vec<u8> = (0..=255).cycle().take(128 * 1024 + 17).collect();
let (temp, mut source) = fixture(&bytes);
assert_eq!(
source.fingerprint(|| false).unwrap(),
format!("{:x}", Sha256::digest(&bytes))
);
assert!(matches!(source.fingerprint(|| true), Err(Error::Cancelled)));
std::fs::write(temp.path().join("bytes"), b"changed").unwrap();
assert!(matches!(source.fingerprint(|| false), Err(Error::Changed)));
}
14 changes: 14 additions & 0 deletions crates/cyber-core/src/worktrees/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,18 @@ impl Repository {
self.claim_owner(execution, managed, server_id).await
}

/// A retained filesystem-only binary worker owns a lease independently of
/// the Session actor, whose lease may already be held by the active turn.
pub async fn claim_binary(
&self,
execution: &dyn GitExecution,
managed: &Managed,
document: &str,
) -> io::Result<CheckoutLease> {
safe_id(document, "bin")?;
self.claim_owner(execution, managed, document).await
}

async fn claim_owner(
&self,
execution: &dyn GitExecution,
Expand Down Expand Up @@ -215,6 +227,8 @@ fn safe_owner_id(id: &str) -> io::Result<()> {
"mcs"
} else if crate::ids::has_prefix(id, "lsp") {
"lsp"
} else if crate::ids::has_prefix(id, "bin") {
"bin"
} else {
"ses"
};
Expand Down
25 changes: 25 additions & 0 deletions crates/cyber-core/tests/worktree_repository/activity.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
use super::*;
use cyber_core::worktrees::CheckoutActivity;

#[test]
fn binary_worker_retains_an_independent_checkout_lease_through_source_commit() {
let fixture = Fixture::new();
let repository = fixture.repository();
let managed = fixture
.create(
&repository,
&Name::parse("binary-worker").unwrap(),
&Settings::default(),
)
.unwrap();
let actor = block_on(repository.claim(&fixture.execution, &managed, "ses_actor")).unwrap();
assert!(block_on(repository.claim_binary(&fixture.execution, &managed, "ses_actor")).is_err());
let worker =
block_on(repository.claim_binary(&fixture.execution, &managed, "bin_worker")).unwrap();
actor.settle().unwrap();
let retained = worker.settle_retained().unwrap();
assert!(
block_on(repository.remove(&fixture.execution, &CheckoutActivity, &managed, false))
.is_err()
);
drop(retained);
block_on(repository.remove(&fixture.execution, &CheckoutActivity, &managed, false)).unwrap();
}

#[test]
fn lsp_owner_remains_independent_and_excludes_removal_through_retained_settlement() {
let fixture = Fixture::new();
Expand Down
Loading
Loading