diff --git a/README.md b/README.md index 1db81998a..34d38da44 100644 --- a/README.md +++ b/README.md @@ -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 `, `/hex `, 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 `, `/hex `, 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 --session --recover ` 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) diff --git a/crates/cyber-app/tests/files.rs b/crates/cyber-app/tests/files.rs index ca0813682..7fee4ee48 100644 --- a/crates/cyber-app/tests/files.rs +++ b/crates/cyber-app/tests/files.rs @@ -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", diff --git a/crates/cyber-cli/src/commands/hex.rs b/crates/cyber-cli/src/commands/hex.rs index 3ba77b6d5..e6e9741e6 100644 --- a/crates/cyber-cli/src/commands/hex.rs +++ b/crates/cyber-cli/src/commands/hex.rs @@ -17,9 +17,25 @@ pub struct HexArgs { /// Open an existing Session without rebinding its Location. #[arg(long)] pub session: Option, - /// 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, +} +fn document_id(value: &str) -> Result { + 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 { match value.strip_prefix("0x") { @@ -75,6 +91,7 @@ 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) } @@ -82,6 +99,50 @@ pub fn run(args: HexArgs, mut launch: LaunchArgs, global: &GlobalArgs) -> Result #[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([ diff --git a/crates/cyber-server/src/http/binary_files/recovery.rs b/crates/cyber-server/src/http/binary_files/recovery.rs index 5a0ff6642..e2a888412 100644 --- a/crates/cyber-server/src/http/binary_files/recovery.rs +++ b/crates/cyber-server/src/http/binary_files/recovery.rs @@ -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, } #[derive(Serialize, JsonSchema)] #[schemars(rename = "BinaryRecoveryEntry")] @@ -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 diff --git a/crates/cyber-tui/src/hex.rs b/crates/cyber-tui/src/hex.rs index 8883ab061..613173dbb 100644 --- a/crates/cyber-tui/src/hex.rs +++ b/crates/cyber-tui/src/hex.rs @@ -30,6 +30,7 @@ pub enum Request { path: String, expected_root: Option, read_only: bool, + recover: Option, offset: u64, generation: u64, }, @@ -56,6 +57,7 @@ pub struct Loaded { pub document: Document, pub offset: u64, pub bytes: Vec, + pub recovered: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -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; @@ -204,6 +214,22 @@ pub fn goto(input: &str, cursor: u64, length: u64) -> Result } impl App { + pub fn recover_hex_at( + &mut self, + id: &str, + path: &str, + root: Option, + offset: u64, + read_only: bool, + ) -> Vec { + 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 { self.open_hex_at(path, None, 0, false) } @@ -239,6 +265,7 @@ impl App { path: path.into(), expected_root, read_only, + recover: None, offset, generation: self.hex.generation, })); @@ -586,6 +613,7 @@ async fn page( document, offset, bytes, + recovered: None, }) } @@ -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")?, @@ -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 @@ -855,6 +906,7 @@ mod ownership_tests { }, offset: 0, bytes: vec![0, 255], + recovered: None, }), }; assert!(apply(&mut app, message).is_empty()); diff --git a/crates/cyber-tui/src/hex/edit.rs b/crates/cyber-tui/src/hex/edit.rs index c0ef68ade..4efc19377 100644 --- a/crates/cyber-tui/src/hex/edit.rs +++ b/crates/cyber-tui/src/hex/edit.rs @@ -9,6 +9,7 @@ pub use client::perform; pub struct State { pub read_only: bool, pub prepared: bool, + pub admission_missing: bool, pub enabled: bool, pub dirty: bool, pub uncertain: bool, @@ -46,6 +47,7 @@ pub enum Command { } #[derive(Debug, Clone)] pub struct Reply { + pub admitted: bool, pub status: Value, pub clipboard: Option, pub review: Option<(cyber_core::binary::receipts::SaveReview, bool)>, @@ -77,6 +79,29 @@ fn status( Ok(status) } +impl State { + pub(super) fn recovered(&mut self, value: Value, document: &Document) -> Result<(), String> { + self.read_only |= value.get("read_only") == Some(&Value::Bool(true)); + let status = status(value, document, None)?; + self.prepared = status.prepared; + self.dirty = status.dirty; + self.uncertain = status.pending_operation.is_some(); + self.enabled = false; + self.admission_missing = true; + self.notice = Some( + if self.uncertain { + "Recovered unknown outcome; Ctrl+R checks retained evidence" + } else if self.read_only { + "Recovered kept draft; read-only view" + } else { + "Recovered kept draft; F2 refreshes edit admission" + } + .into(), + ); + Ok(()) + } +} + impl App { fn hex_edit_request(&mut self, command: Command) -> Vec { let Some(document) = self.hex.document.clone() else { @@ -181,27 +206,39 @@ impl App { fn hex_edit_command(&mut self, key: KeyEvent) -> Option { match self.keymap.action("hex", &key, false) { Some("hex_edit") if !self.hex.edit.read_only => { - if self.hex.edit.prepared { + if self.hex.edit.prepared && !self.hex.edit.admission_missing { self.hex.edit.enabled = !self.hex.edit.enabled; return None; } Some(Command::Prepare) } Some("hex_reconcile") => Some(Command::Reconcile), - Some("hex_save") if self.hex.edit.prepared => match self.hex.edit.review.as_ref() { - Some((review, restore)) => Some(Command::Save { - fingerprint: review.fingerprint.clone(), - restore: *restore, - }), - None => Some(Command::Review { - restore: false, + Some("hex_save") + if self.hex.edit.prepared + && !self.hex.edit.admission_missing + && !self.hex.edit.read_only => + { + match self.hex.edit.review.as_ref() { + Some((review, restore)) => Some(Command::Save { + fingerprint: review.fingerprint.clone(), + restore: *restore, + }), + None => Some(Command::Review { + restore: false, + start: 0, + }), + } + } + Some("hex_restore") + if self.hex.edit.prepared + && !self.hex.edit.admission_missing + && !self.hex.edit.read_only => + { + Some(Command::Review { + restore: true, start: 0, - }), - }, - Some("hex_restore") if self.hex.edit.prepared => Some(Command::Review { - restore: true, - start: 0, - }), + }) + } Some("hex_undo") if self.hex.edit.enabled => Some(Command::History("undo")), Some("hex_redo") if self.hex.edit.enabled => Some(Command::History("redo")), _ => None, @@ -291,12 +328,22 @@ impl App { match key.code { KeyCode::Esc => self.hex.edit.closing = false, KeyCode::Char('k') => { + if let Some(document) = &self.hex.document { + self.toast(format!( + "Kept draft {} · reopen with --session {} --recover {}", + document.id, self.session.id, document.id + )); + } self.hex.generation = self.hex.generation.wrapping_add(1); self.overlay = Overlay::None; return self.close_hex_document(); } KeyCode::Char('d') if !self.hex.edit.uncertain && !self.hex.loading => { self.hex.edit.closing = false; + if self.hex.edit.admission_missing || self.hex.edit.read_only { + self.hex.error = Some("Refresh edit admission with F2 before discarding; read-only views can keep the draft".into()); + return vec![]; + } return self.hex_edit_request(Command::History("discard")); } _ => {} @@ -370,7 +417,11 @@ impl App { document.length = status.metadata.length.0; self.hex.cursor = self.hex.cursor.min(document.length); self.hex.edit.prepared = status.prepared; - self.hex.edit.enabled = status.prepared && !self.hex.edit.read_only; + if reply.admitted { + self.hex.edit.admission_missing = false; + } + self.hex.edit.enabled = + status.prepared && !self.hex.edit.read_only && !self.hex.edit.admission_missing; self.hex.edit.dirty = status.dirty; self.hex.edit.uncertain = status.pending_operation.is_some(); self.hex.edit.review = reply.review; @@ -515,6 +566,7 @@ mod tests { directory, generation: generation.wrapping_sub(1), result: Ok(Reply { + admitted: false, clipboard: None, status: json!({"path":"bytes","revision":"stale","source":"draft","length":"1"}), review: None, diff --git a/crates/cyber-tui/src/hex/edit_client.rs b/crates/cyber-tui/src/hex/edit_client.rs index c1c202922..314b29c98 100644 --- a/crates/cyber-tui/src/hex/edit_client.rs +++ b/crates/cyber-tui/src/hex/edit_client.rs @@ -20,6 +20,7 @@ pub async fn perform( .map_err(|e| e.to_string())?; if response["data"]["pending_operation"].is_null() { return Ok(Reply { + admitted: false, clipboard: None, status: response["data"].clone(), review: None, @@ -109,6 +110,7 @@ pub async fn perform( .clone() }; Ok(Reply { + admitted: route == "prepare", clipboard: None, status, review, @@ -142,6 +144,7 @@ async fn copy( return Err("Selection revision changed".into()); } Ok(Reply { + admitted: false, status: response["data"].clone(), clipboard: Some(hex), review: None, diff --git a/crates/cyber-tui/src/lib.rs b/crates/cyber-tui/src/lib.rs index 2e51246cd..f6e865b2e 100644 --- a/crates/cyber-tui/src/lib.rs +++ b/crates/cyber-tui/src/lib.rs @@ -150,6 +150,7 @@ pub struct HexStart { pub root: String, pub offset: u64, pub read_only: bool, + pub recover: Option, } pub struct TuiOptions { diff --git a/crates/cyber-tui/src/runner.rs b/crates/cyber-tui/src/runner.rs index d2be42d06..2aef12793 100644 --- a/crates/cyber-tui/src/runner.rs +++ b/crates/cyber-tui/src/runner.rs @@ -475,12 +475,17 @@ fn initial_actions( }); } if let Some(hex) = &opts.hex { - initial.extend(app.open_hex_at( - &hex.path, - Some(hex.root.clone()), - hex.offset, - hex.read_only, - )); + initial.extend(if let Some(id) = &hex.recover { + app.recover_hex_at( + id, + &hex.path, + Some(hex.root.clone()), + hex.offset, + hex.read_only, + ) + } else { + app.open_hex_at(&hex.path, Some(hex.root.clone()), hex.offset, hex.read_only) + }); } if let Some(prompt) = &opts.prompt { initial.push(Action::Prompt { diff --git a/crates/cyber-tui/src/screens_explorer.rs b/crates/cyber-tui/src/screens_explorer.rs index 4d7919c45..77813b76f 100644 --- a/crates/cyber-tui/src/screens_explorer.rs +++ b/crates/cyber-tui/src/screens_explorer.rs @@ -968,6 +968,97 @@ async fn hex_stale_open_is_closed_and_session_switch_forgets_private_bytes() { } } +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[tokio::test] +async fn hex_kept_draft_reopens_without_source_changes_and_requires_fresh_edit_admission() { + let f = Fixture::new().await; + std::fs::write(f.work.join("kept.bin"), [0, 255, 128, 27]).unwrap(); + let mut app = f.app().await; + let before = f + .server + .client + .get(&format!("/sessions/{}/messages", app.session.id)) + .await + .unwrap(); + let actions = app.open_hex("kept.bin"); + f.run(&mut app, actions).await; + let actions = press(&mut app, KeyCode::F(2)); + f.run(&mut app, actions).await; + press(&mut app, KeyCode::Char('4')); + let actions = press(&mut app, KeyCode::Char('1')); + f.run(&mut app, actions).await; + let id = app.hex.document.as_ref().unwrap().id.clone(); + let actions = press(&mut app, KeyCode::Esc); + f.run(&mut app, actions).await; + let actions = press(&mut app, KeyCode::Char('k')); + f.run(&mut app, actions).await; + assert!(app.hex.document.is_none()); + assert!( + app.toast + .as_ref() + .unwrap() + .0 + .contains(&format!("--recover {id}")) + ); + let actions = app.recover_hex_at(&id, "kept.bin", Some("/wrong/project".into()), 0, false); + f.run(&mut app, actions).await; + assert!(app.hex.error.is_some()); + assert!(app.hex.bytes.is_empty()); + let root = f.work.display().to_string(); + let actions = app.recover_hex_at(&id, "wrong.bin", Some(root.clone()), 0, false); + f.run(&mut app, actions).await; + assert!(app.hex.error.is_some()); + assert!(app.hex.bytes.is_empty()); + let actions = app.recover_hex_at(&id, "kept.bin", Some(root.clone()), 0, false); + f.run(&mut app, actions).await; + assert!(app.hex.error.is_none(), "{:?}", app.hex.error); + assert_eq!(app.hex.bytes, [65, 255, 128, 27]); + assert!(app.hex.edit.dirty && app.hex.edit.admission_missing); + assert!(!app.hex.edit.enabled); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert!(actions.is_empty()); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert!(!app.hex.edit.enabled && app.hex.edit.admission_missing); + let actions = press(&mut app, KeyCode::F(2)); + f.run(&mut app, actions).await; + assert!( + app.hex.edit.enabled && !app.hex.edit.admission_missing, + "{:?}", + app.hex.error + ); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert_eq!(app.hex.bytes, [0, 255, 128, 27]); + let actions = app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + f.run(&mut app, actions).await; + assert_eq!(app.hex.bytes, [65, 255, 128, 27]); + let actions = press(&mut app, KeyCode::Esc); + f.run(&mut app, actions).await; + let actions = press(&mut app, KeyCode::Char('k')); + f.run(&mut app, actions).await; + let actions = app.recover_hex_at(&id, "kept.bin", Some(root), 0, true); + f.run(&mut app, actions).await; + assert!(app.hex.edit.read_only && app.hex.edit.dirty && !app.hex.edit.enabled); + let actions = press(&mut app, KeyCode::F(2)); + f.run(&mut app, actions).await; + assert!(!app.hex.edit.enabled); + assert_eq!( + std::fs::read(f.work.join("kept.bin")).unwrap(), + [0, 255, 128, 27] + ); + let after = f + .server + .client + .get(&format!("/sessions/{}/messages", app.session.id)) + .await + .unwrap(); + assert_eq!( + before["data"], after["data"], + "recovery never starts inference" + ); +} + #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test] async fn hex_edit_review_save_undo_and_restore_use_real_owner_routes() { diff --git a/crates/cyber-tui/src/view/hex.rs b/crates/cyber-tui/src/view/hex.rs index ead684550..34148c9c4 100644 --- a/crates/cyber-tui/src/view/hex.rs +++ b/crates/cyber-tui/src/view/hex.rs @@ -283,7 +283,7 @@ fn review_lines( fn status(h: &Workspace) -> String { if h.edit.closing { - if h.edit.uncertain || h.loading { + if h.edit.uncertain || h.loading || h.edit.admission_missing || h.edit.read_only { "Keep retained state? K keep and close · Esc stay".into() } else { "Unsaved draft: K keep and close · D discard · Esc stay".into() diff --git a/docs/binary-editor.md b/docs/binary-editor.md index 61010e697..0601883b2 100644 --- a/docs/binary-editor.md +++ b/docs/binary-editor.md @@ -151,6 +151,20 @@ and refresh status before explicitly choosing a follow-up. Closing releases the in-memory handle, retaining private commands and receipts. Use discovery in the same Session Location, recover the selected ID, then prepare again before changing its draft. Recovery does not install bytes or replay a save. +Keep-and-close displays the retained document identity and original Session. +Reopen that manual draft in the terminal: + +```sh +cyber --cwd /path/to/project hex fixtures/header.bin --session ses_… --recover bin_… +``` + +The project and file must match the retained namespace. Recovery starts in view +mode with dirty/unknown state preserved. F2 obtains fresh edit admission before +editing, undo/redo, discard or save; a status/reconciliation response does not grant +that admission. `--read-only` keeps the recovered document immutable. Ctrl+R checks +an uncertain outcome without another source replacement. Recovery does not start +inference or turn a model invocation namespace into a manual draft. + A replaced/aliased parent refuses access; an ordinary changed source remains separate from the private draft. Recovery uses its exclusive private document lock to settle matching filesystem-only activity records left by a prior process. It refuses active diff --git a/docs/cli.md b/docs/cli.md index 193fc6a9f..bce14de44 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -100,7 +100,8 @@ cyber hex [OPTIONS] - `--offset `: Initial byte offset, as decimal or 0x hexadecimal. Default: `0`. - `--session `: Open an existing Session without rebinding its Location. -- `--read-only`: Inspect bytes without entering an edit draft. +- `--read-only`: Inspect bytes without enabling draft edits. +- `--recover `: Reopen a kept manual draft in its original --session. ## `cyber login` diff --git a/docs/goal-delivery.md b/docs/goal-delivery.md index c821fc332..fb6879c86 100644 --- a/docs/goal-delivery.md +++ b/docs/goal-delivery.md @@ -20,6 +20,17 @@ stay tracked in issues #92 and #93. Their separate pending policy decisions are not selected by this scheduling change. The binary workspace implementation is partial; its live view does not complete the editor goal. +On 2026-11-03 the user requested merging #105 and moving to [daily coding +issue #93](https://github.com/CyberdyneCorp/cybercode-cli/issues/93). Daily coding +is now the next priority after publishing the already tested manual-draft CLI +recovery increment. This supersedes the old requirement to finish #92 before +starting #93. Plugins/hooks remain incomplete; their pending design decisions +and enforcement guarantees are not implicitly accepted. Remaining binary +retention/checkpoint/rewind integration and UX/performance acceptance are also +unfinished despite the owner's manual closure of #94. Follow the approved daily +coding implementation order, beginning with rewind preview/confirmation; preserve +shared snapshot, permission, recovery and FileChanged contracts. + ## Delivery constraint approved on 2026-10-29 The user requested fewer simultaneously open PRs. Keep normally **one open goal diff --git a/openspec/changes/add-binary-hex-editor/design.md b/openspec/changes/add-binary-hex-editor/design.md index ccfd99956..e2799a73f 100644 --- a/openspec/changes/add-binary-hex-editor/design.md +++ b/openspec/changes/add-binary-hex-editor/design.md @@ -85,3 +85,18 @@ are separate actions, not side effects of receipt lookup. ## Delivery and acceptance The user prioritized implementation on 2026-11-01 ahead of the unfinished first two workstreams. Preserve shared contracts and implement in this internal sequence: byte-service native identity/storage proof; bounded view/search and SDK; staged editing/save/recovery; themed TUI/direct CLI; model patch integration; snapshot/hooks and acceptance. Tests must cover every byte value, empty files, NUL/invalid UTF-8, offsets above 2^53, sparse files, EOF/overflow, same-size/same-mtime changes, denied reads/edits, partial nibbles, multi-splice atomicity, file growth/shrinkage, history, stale revisions and Session switches, lost acknowledgements, kill/restart around replacement and native alias races. Use real binary fixtures and hashes, TUI snapshots at wide/narrow/NO_COLOR widths plus real terminal captures, native platform positive/negative controls, generated schema/API/CLI checks and code/security review. See [implementation progress](implementation.md) for the unchanged-main browsing baseline, current viewer evidence and unperformed editor/native acceptance. + + +### Reopening a kept manual draft from the CLI + +`cyber hex --session --recover ` reopens a kept +manual namespace through the owner recovery route. The server checks an optional +expected project root before reading its private journal; the terminal also checks +the returned file path and retires a mismatched response's view. The document ID is +validated before transport. CLI recovery requires the original Session and supports +`--read-only`; no model inference or source mutation is performed on open. + +Recovered dirty/unknown flags and private undo/redo remain visible, but recovery +never restores an old edit grant. The terminal tracks missing edit admission until +an explicit successful F2 prepare reply. Status/copy/reconciliation replies cannot +enable editing or saving; an immutable read-only view cannot obtain admission. diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index 14c2bbdf1..cf079d63f 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -709,3 +709,36 @@ RSS was 7.45 MiB, excluding filesystem cache. These core-only observations exclu server, permission, terminal rendering/input and provider work; they do not close the broader UI/platform/performance acceptance task. The benchmark target passed core all-target Clippy. + +### Kept manual draft terminal recovery + +`cyber hex --session --recover ` reopens +retained manual commands without allocating a new draft or starting inference. +Keep-and-close displays the recovery identity. An optional expected project root +is checked before private journal access, and the terminal checks the returned file +path before reading a page. A mismatched view is closed while its journal remains. + +Recovery hydrates dirty/unknown state and retains undo/redo, but starts in view +mode. Only an explicit successful F2 prepare reply renews edit admission; copy, +status and reconciliation cannot enable edits or save. `--read-only` cannot renew +admission. The close prompt offers keep-only while admission is missing. README, +feature guide, generated CLI help, OpenAPI and TypeScript helpers document this +scope. Configured retention/checkpoint/rewind and remaining UI/platform acceptance +remain outstanding; this increment does not complete #94. + +Local verification for terminal recovery: 79 CLI tests (including generated-help +consistency), 16 hex TUI tests, and the closed-dirty-draft production-router +regression passed. The latter now also rejects a mismatched expected root before +successful recovery. SDK typecheck and tests passed (125 passed, 10 external tests +skipped). OpenAPI regeneration, strict OpenSpec validation (114 items), spec lint +(0 errors, 25 warnings), formatting and diff checks passed. CLI/TUI/server/app +all-target Clippy passed. Local runtime verification is macOS; cross-platform CI +for this increment remains pending. + +After rebasing onto merged #105 (`a6165c18`), the 16 hex TUI tests and +closed-dirty-draft production-router regression passed again. The combined tree +preserves resource-bound resume alongside expected-root admission. OpenAPI +regeneration/check, SDK typecheck and 127 tests (10 external skips), strict +OpenSpec validation (114 items) and diff checks passed. The user prioritized #93 +next; the delivery guide and daily-coding proposal/design/tasks record that +scheduling change without accepting unfinished #92/#94 scope. diff --git a/openspec/changes/add-binary-hex-editor/specs/cli-commands/spec.md b/openspec/changes/add-binary-hex-editor/specs/cli-commands/spec.md index f910d2539..771a9c6d0 100644 --- a/openspec/changes/add-binary-hex-editor/specs/cli-commands/spec.md +++ b/openspec/changes/add-binary-hex-editor/specs/cli-commands/spec.md @@ -28,3 +28,8 @@ This list is the single registry of top-level commands; no other spec SHALL intr #### Scenario: Direct terminal entry - **WHEN** an owner opens a project binary with `cyber hex fixtures/sample.bin --offset 0x20 --read-only` - **THEN** the view starts at exact offset 32 without model inference, draft mutation or a write on exit + +#### Scenario: Reopen a kept manual draft +- **WHEN** the user runs `cyber hex --session --recover ` +- **THEN** the owner recovery route checks the project and namespace, the terminal verifies the file path, and the draft opens in view mode without inference or source changes +- **AND** dirty/unknown state and private undo/redo are retained, while an explicit fresh edit admission is required before editing or saving diff --git a/openspec/changes/add-binary-hex-editor/specs/tui/spec.md b/openspec/changes/add-binary-hex-editor/specs/tui/spec.md index 1cc585909..6ec00f3a1 100644 --- a/openspec/changes/add-binary-hex-editor/specs/tui/spec.md +++ b/openspec/changes/add-binary-hex-editor/specs/tui/spec.md @@ -61,3 +61,8 @@ The explorer SHALL never bypass permissions. Listing SHALL be evaluated as `list #### Scenario: Save finishes after a Session switch - **WHEN** a response arrives for a binary document after the user switches Session or Location - **THEN** it cannot repaint the new document, replace its draft or authorize another save + +#### Scenario: Recovered status does not grant edit admission +- **WHEN** a kept draft is recovered or its status is reconciled +- **THEN** the terminal preserves dirty/unknown state and keeps editing disabled until F2 explicitly obtains fresh admission +- **AND** a read-only launch cannot enable edits or save, and a stale recovered response is retired under its original Session and Location diff --git a/openspec/changes/complete-daily-coding-surfaces/design.md b/openspec/changes/complete-daily-coding-surfaces/design.md index 92c95099f..838fd6bfb 100644 --- a/openspec/changes/complete-daily-coding-surfaces/design.md +++ b/openspec/changes/complete-daily-coding-surfaces/design.md @@ -40,4 +40,4 @@ Emit schema_version 2, one result per admitted input with stable correlation, op ## Delivery and acceptance -Implement in order: revert spec reconciliation and CLI/TUI; non-git snapshots; path rules/context inspection; root structured output; JSONL driver. Each increment includes API/SDK/CLI generation, regressions, docs and meaningful production-path acceptance. Use actual temporary Git and non-git files, scripted provider/server process tests, TUI TestBackend plus real captures, crash/race tests, and native checks for filesystem behavior. Perform code/security review before delivery. Existing stale task headings are reconciled only against demonstrated clauses. This workstream starts after the accepted Plugins and hooks slice, and remains active until all approved scope is implemented and the required checks have recorded outcomes. +Implement in order: revert spec reconciliation and CLI/TUI; non-git snapshots; path rules/context inspection; root structured output; JSONL driver. Each increment includes API/SDK/CLI generation, regressions, docs and meaningful production-path acceptance. Use actual temporary Git and non-git files, scripted provider/server process tests, TUI TestBackend plus real captures, crash/race tests, and native checks for filesystem behavior. Perform code/security review before delivery. Existing stale task headings are reconciled only against demonstrated clauses. On 2026-11-03 the user prioritized #93 after #105, superseding the original Plugins/hooks scheduling prerequisite. Start with the approved rewind slice while preserving unresolved cross-workstream dependencies; this scheduling change does not accept incomplete plugin enforcement or select pending policies. The workstream remains incomplete until all approved scope is implemented and the required checks have recorded outcomes. diff --git a/openspec/changes/complete-daily-coding-surfaces/proposal.md b/openspec/changes/complete-daily-coding-surfaces/proposal.md index c53fdf554..31e7e5044 100644 --- a/openspec/changes/complete-daily-coding-surfaces/proposal.md +++ b/openspec/changes/complete-daily-coding-surfaces/proposal.md @@ -29,4 +29,4 @@ None. This completes existing daily-use requirements. Primary areas: runtime revert/branches/inbox/structured output, snapshot storage and GC, instruction discovery and Context Epoch assembly, CLI/TUI commands, authenticated API/OpenAPI and SDKs. Branch/session exports and older event replay must remain compatible. Existing child structured-output contracts remain intact. -This is the second workstream of the requested goal. Prepare/review its artifacts now, but start implementation only after the Plugins and hooks workstream has accepted evidence. This does not include IDE clients, Relay, persistent schedules, new model providers, general configuration import, native computer ports or a new plan/agent framework. +This is the second workstream of the requested goal. The original prerequisite to complete Plugins and hooks was superseded on 2026-11-03 when the user prioritized #93 after merging #105. Begin this approved workstream while retaining unresolved Plugins/hooks dependencies and acceptance gates. This does not include IDE clients, Relay, persistent schedules, new model providers, general configuration import, native computer ports or a new plan/agent framework. diff --git a/openspec/changes/complete-daily-coding-surfaces/tasks.md b/openspec/changes/complete-daily-coding-surfaces/tasks.md index b0ad13d63..a1d549fd9 100644 --- a/openspec/changes/complete-daily-coding-surfaces/tasks.md +++ b/openspec/changes/complete-daily-coding-surfaces/tasks.md @@ -3,7 +3,7 @@ - [x] 1.1 Inspect rewind, non-git snapshot, rule/context and structured-output/JSONL contracts and existing reusable machinery at `097b6278`. - [x] 1.2 Draft proposal/design/scenarios and ordered tasks; identify destructive-revert wording and retry-count inconsistencies. - [x] 1.3 Review artifacts with the user, including platform limits, the additive instruction_limits setting, schema/JSONL bounds, multi-input exits and interrupt/EOF semantics. Approved on 2026-10-27: after PRs #53/#54 merged, the user explicitly requested implementation of both merged plans and then instructed goal resume. Native proof, baseline and acceptance gates remain required. -- [ ] 1.4 Record accepted Plugins and hooks completion before starting this workstream. Establish relevant unchanged-main baseline checks and current clause coverage. +- [ ] 1.4 Record the 2026-11-03 user-approved priority change, establish relevant unchanged-main baseline checks and current clause coverage, and retain unresolved Plugins/hooks dependencies without claiming acceptance. The scheduling prerequisite is superseded; baseline/coverage verification remains required. ## 2. Rewind and undo/redo diff --git a/sdk/openapi.json b/sdk/openapi.json index 4be451978..ca544dc60 100644 --- a/sdk/openapi.json +++ b/sdk/openapi.json @@ -15655,6 +15655,12 @@ "read_only": { "type": "boolean", "default": false + }, + "expected_root": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/sdk/typescript/src/binary.ts b/sdk/typescript/src/binary.ts index 766b21ab1..f26673a9a 100644 --- a/sdk/typescript/src/binary.ts +++ b/sdk/typescript/src/binary.ts @@ -82,9 +82,10 @@ function metadataFields(status: BinaryStatus): Omit { - const { readOnly = false, ...requestOptions } = options; - const response = await client.request("v1.fs.binaryRecover", { path: { id }, body: { session_id: sessionId, read_only: readOnly } }, requestOptions); + options: RequestOptions & { readOnly?: boolean; expectedRoot?: string } = {}): Promise { + if (!/^bin_[a-zA-Z0-9_]{1,96}$/.test(id)) throw new TypeError("Invalid retained binary identity"); + const { readOnly = false, expectedRoot, ...requestOptions } = options; + const response = await client.request("v1.fs.binaryRecover", { path: { id }, body: { session_id: sessionId, read_only: readOnly, expected_root: expectedRoot } }, requestOptions); return binaryHandle(client, sessionId, id, metadataFields(response.data)); } diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 7cff5247c..480833b8f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -65,7 +65,7 @@ export class CyberClient implements Caller { /** Inspect retained invocation receipts without promoting private drafts. */ model: modelBinaryRecovery(this), /** Reopen retained private commands/receipts without replaying a source save. */ - recover: (sessionID: string, documentID: string, options: RequestOptions & { readOnly?: boolean } = {}): Promise => recoverBinaryDocument(this, sessionID, documentID, options), + recover: (sessionID: string, documentID: string, options: RequestOptions & { readOnly?: boolean; expectedRoot?: string } = {}): Promise => recoverBinaryDocument(this, sessionID, documentID, options), /** Open a live exact-byte view scoped to a Session and this client's Location. */ open: (sessionID: string, path: string, options: RequestOptions & { expectedRoot?: string; readOnly?: boolean } = {}): Promise => openBinaryDocument(this, sessionID, path, options), }; diff --git a/sdk/typescript/src/generated/types.ts b/sdk/typescript/src/generated/types.ts index 8644b280a..af271d5a2 100644 --- a/sdk/typescript/src/generated/types.ts +++ b/sdk/typescript/src/generated/types.ts @@ -197,6 +197,7 @@ export type BinaryReconcileRequest = { export type BinaryRecoverRequest = { session_id: string; read_only?: boolean; + expected_root?: string | null; }; export type BinaryRecoveryEntry = { diff --git a/sdk/typescript/test/binary.test.ts b/sdk/typescript/test/binary.test.ts index 1264231e9..18afefa6a 100644 --- a/sdk/typescript/test/binary.test.ts +++ b/sdk/typescript/test/binary.test.ts @@ -78,11 +78,13 @@ test("binary draft helpers retain explicit retry revisions and never convert byt assert.equal(patches.length, 2); assert.equal(patches[0]!.body, patches[1]!.body, "retry changed its revision after acknowledgement"); assert.deepEqual(await doc.operation("patch_once"), receipt); - const recovered = await client.binary.recover("ses_one", doc.id, { readOnly: true }); + const recovered = await client.binary.recover("ses_one", doc.id, { readOnly: true, expectedRoot: "/work" }); assert.equal(recovered.id, doc.id); assert.equal(recovered.metadata.source, "draft"); assert.equal(JSON.parse(calls.at(-1)!.body!).read_only, true); + assert.equal(JSON.parse(calls.at(-1)!.body!).expected_root, "/work"); const before = calls.length; + await assert.rejects(() => client.binary.recover("ses_one", "../bin_one")); await assert.rejects(() => doc.patch("bad/id", batch)); await assert.rejects(() => doc.patch("bad_bytes", { ...batch, splices: [{ ...batch.splices[0]!, expected_hex: "0" }] })); await assert.rejects(() => doc.patch("wrong_length", { ...batch, splices: [{ ...batch.splices[0]!, delete_length: "2" }] }));