1use std::collections::{BTreeMap, BTreeSet};
10use std::fs::{self as stdfs};
11use std::io::Write;
12use std::path::{Component, Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14
15use harn_vm::agent_events::AgentEvent;
16use harn_vm::process_sandbox::{check_fs_path_scope, FsAccess};
17use harn_vm::VmValue;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::error::HostlibError;
22use crate::registry::{BuiltinRegistry, HostlibCapability};
23use crate::tools::args::{
24 build_dict, dict_arg, optional_bool, optional_int, optional_string, optional_string_list,
25 require_string, resolve_host_path, str_value, to_agent_path,
26};
27use crate::tools::permissions::enforce_path_scope;
28
29const SET_MODE_BUILTIN: &str = "hostlib_fs_set_mode";
30const STATUS_BUILTIN: &str = "hostlib_fs_staged_status";
31const COMMIT_BUILTIN: &str = "hostlib_fs_commit_staged";
32const DISCARD_BUILTIN: &str = "hostlib_fs_discard_staged";
33const SAFE_TEXT_PATCH_BUILTIN: &str = "hostlib_fs_safe_text_patch";
34const READ_TEXT_BUILTIN: &str = "hostlib_fs_read_text";
35const EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN: &str = "hostlib_fs_emit_safe_text_patch_result";
36
37const MANIFEST_VERSION: u32 = 1;
38const STATE_REL: &[&str] = &[".harn", "state", "staged"];
39
40mod paths;
41#[cfg(test)]
42mod tests;
43mod wire;
44
45pub use harn_vm::conditional_replace::{
46 scope_conditional_replace_lock_root, ScopedConditionalReplaceLockRoot,
47};
48use paths::{
49 active_session_id, default_root, manifest_path, normalize_logical, not_found, session_dir,
50 validate_session_id,
51};
52use wire::{commit_result_to_value, discard_result_to_value, status_to_value};
53
54#[derive(Default)]
56pub struct FsCapability;
57
58impl HostlibCapability for FsCapability {
59 fn module_name(&self) -> &'static str {
60 "fs"
61 }
62
63 fn register_builtins(&self, registry: &mut BuiltinRegistry) {
64 registry.register_fn("fs", SET_MODE_BUILTIN, "set_mode", set_mode_builtin);
65 registry.register_fn("fs", STATUS_BUILTIN, "staged_status", staged_status_builtin);
66 registry.register_fn("fs", COMMIT_BUILTIN, "commit_staged", commit_staged_builtin);
67 registry.register_fn(
68 "fs",
69 DISCARD_BUILTIN,
70 "discard_staged",
71 discard_staged_builtin,
72 );
73 registry.register_gated_fn(
76 "fs",
77 SAFE_TEXT_PATCH_BUILTIN,
78 "safe_text_patch",
79 safe_text_patch_builtin,
80 );
81 registry.register_gated_fn("fs", READ_TEXT_BUILTIN, "read_text", read_text_builtin);
82 registry.register_fn(
83 "fs",
84 EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
85 "emit_safe_text_patch_result",
86 emit_safe_text_patch_result_builtin,
87 );
88 }
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "lowercase")]
94pub enum FsMode {
95 Immediate,
97 Staged,
99}
100
101impl FsMode {
102 fn parse(builtin: &'static str, raw: &str) -> Result<Self, HostlibError> {
103 match raw {
104 "immediate" => Ok(Self::Immediate),
105 "staged" => Ok(Self::Staged),
106 other => Err(HostlibError::InvalidParameter {
107 builtin,
108 param: "mode",
109 message: format!("expected \"immediate\" or \"staged\", got `{other}`"),
110 }),
111 }
112 }
113
114 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Immediate => "immediate",
118 Self::Staged => "staged",
119 }
120 }
121}
122
123#[derive(Clone, Debug, Serialize, Deserialize)]
124struct Manifest {
125 version: u32,
126 session_id: String,
127 mode: FsMode,
128 root: String,
129 entries: BTreeMap<String, StagedEntry>,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134enum StagedEntry {
135 Write {
136 body_hash: String,
137 len: u64,
138 created_at_ms: i64,
139 },
140 Delete {
141 recursive: bool,
142 created_at_ms: i64,
143 },
144}
145
146impl StagedEntry {
147 fn created_at_ms(&self) -> i64 {
148 match self {
149 Self::Write { created_at_ms, .. } | Self::Delete { created_at_ms, .. } => {
150 *created_at_ms
151 }
152 }
153 }
154
155 fn body_len(&self) -> u64 {
156 match self {
157 Self::Write { len, .. } => *len,
158 Self::Delete { .. } => 0,
159 }
160 }
161}
162
163#[derive(Clone, Debug)]
164struct SessionState {
165 session_id: String,
166 mode: FsMode,
167 root: PathBuf,
168 entries: BTreeMap<PathBuf, StagedEntry>,
169}
170
171#[derive(Clone, Debug)]
172pub(crate) struct WriteOutcome {
173 pub(crate) created: bool,
174 pub(crate) bytes_written: usize,
175}
176
177#[derive(Clone, Debug)]
178pub(crate) struct OverlayDirEntry {
179 pub(crate) name: String,
180 pub(crate) is_dir: bool,
181 pub(crate) is_symlink: bool,
182 pub(crate) size: u64,
183}
184
185#[derive(Clone, Debug)]
187pub struct StagedStatus {
188 pub pending_writes: Vec<PendingWrite>,
190 pub total_bytes_pending: u64,
192 pub oldest_pending_age_ms: i64,
194}
195
196#[derive(Clone, Debug)]
197pub struct PendingWrite {
199 pub path: String,
201 pub kind: &'static str,
203 pub bytes_added: u64,
205 pub bytes_removed: u64,
207}
208
209#[derive(Clone, Debug)]
211pub struct SetModeResult {
212 pub previous_mode: FsMode,
214}
215
216#[derive(Clone, Debug)]
218pub struct CommitResult {
219 pub committed_paths: Vec<String>,
221 pub failed_paths_with_reasons: Vec<(String, String)>,
223}
224
225#[derive(Clone, Debug)]
227pub struct DiscardResult {
228 pub discarded_paths: Vec<String>,
230}
231
232static SESSIONS: OnceLock<Mutex<BTreeMap<String, SessionState>>> = OnceLock::new();
233
234fn sessions() -> &'static Mutex<BTreeMap<String, SessionState>> {
235 SESSIONS.get_or_init(|| Mutex::new(BTreeMap::new()))
236}
237
238fn lock_sessions() -> std::sync::MutexGuard<'static, BTreeMap<String, SessionState>> {
242 sessions()
243 .lock()
244 .expect("hostlib fs session mutex poisoned")
245}
246
247pub fn configure_session_root(session_id: &str, root: &Path) {
252 if session_id.trim().is_empty() {
253 return;
254 }
255 let root = normalize_logical(root);
256 let mut guard = lock_sessions();
257 match guard.get_mut(session_id) {
258 Some(state) if state.entries.is_empty() => {
259 state.root = root;
260 }
261 Some(_) => {}
262 None => {
263 let state = load_state(session_id, Some(root.clone())).unwrap_or(SessionState {
264 session_id: session_id.to_string(),
265 mode: FsMode::Immediate,
266 root,
267 entries: BTreeMap::new(),
268 });
269 guard.insert(session_id.to_string(), state);
270 }
271 }
272}
273
274pub fn configured_session_root(session_id: &str) -> Option<PathBuf> {
276 if session_id.trim().is_empty() {
277 return None;
278 }
279 let guard = lock_sessions();
280 guard.get(session_id).map(|state| state.root.clone())
281}
282
283pub fn set_mode(
285 session_id: &str,
286 mode: FsMode,
287 root: Option<&Path>,
288) -> Result<SetModeResult, HostlibError> {
289 validate_session_id(SET_MODE_BUILTIN, session_id)?;
290 let mut guard = lock_sessions();
291 let mut state = state_for_locked(&mut guard, session_id, root.map(normalize_logical))?;
292 let previous_mode = state.mode;
293 state.mode = mode;
294 persist_state(&state, "set_mode", None).map_err(|err| HostlibError::Backend {
295 builtin: SET_MODE_BUILTIN,
296 message: err,
297 })?;
298 guard.insert(session_id.to_string(), state);
299 Ok(SetModeResult { previous_mode })
300}
301
302pub fn staged_status(session_id: &str) -> Result<StagedStatus, HostlibError> {
304 validate_session_id(STATUS_BUILTIN, session_id)?;
305 let mut guard = lock_sessions();
306 let state = state_for_locked(&mut guard, session_id, None)?;
307 let status = status_from_state(&state);
308 guard.insert(session_id.to_string(), state);
309 Ok(status)
310}
311
312pub(crate) fn staged_pending_paths(session_id: &str) -> Result<BTreeSet<PathBuf>, HostlibError> {
319 validate_session_id(STATUS_BUILTIN, session_id)?;
320 let mut guard = lock_sessions();
321 let state = state_for_locked(&mut guard, session_id, None)?;
322 let paths = state.entries.keys().cloned().collect();
323 guard.insert(session_id.to_string(), state);
324 Ok(paths)
325}
326
327pub fn commit_staged(session_id: &str, paths: &[String]) -> Result<CommitResult, HostlibError> {
329 validate_session_id(COMMIT_BUILTIN, session_id)?;
330 let mut guard = lock_sessions();
331 let mut state = state_for_locked(&mut guard, session_id, None)?;
332 let selected = selected_paths(&state, paths);
333 let mut committed_paths = Vec::new();
334 let mut failed_paths_with_reasons = Vec::new();
335
336 for path in selected {
337 let Some(entry) = state.entries.get(&path).cloned() else {
338 continue;
339 };
340 let path_label = to_agent_path(&path);
341 let access = match entry {
347 StagedEntry::Write { .. } => FsAccess::Write,
348 StagedEntry::Delete { .. } => FsAccess::Delete,
349 };
350 if let Err(violation) = check_fs_path_scope(&path, access) {
351 failed_paths_with_reasons.push((path_label, violation.message(COMMIT_BUILTIN)));
352 continue;
353 }
354 match commit_entry(&state, &path, &entry) {
355 Ok(()) => {
356 state.entries.remove(&path);
357 committed_paths.push(path_label);
358 }
359 Err(reason) => failed_paths_with_reasons.push((path_label, reason)),
360 }
361 }
362
363 persist_state(&state, "commit_staged", None).map_err(|err| HostlibError::Backend {
364 builtin: COMMIT_BUILTIN,
365 message: err,
366 })?;
367 emit_staged_update(&state);
368 guard.insert(session_id.to_string(), state);
369 Ok(CommitResult {
370 committed_paths,
371 failed_paths_with_reasons,
372 })
373}
374
375pub fn discard_staged(session_id: &str, paths: &[String]) -> Result<DiscardResult, HostlibError> {
377 validate_session_id(DISCARD_BUILTIN, session_id)?;
378 let mut guard = lock_sessions();
379 let mut state = state_for_locked(&mut guard, session_id, None)?;
380 let selected = selected_paths(&state, paths);
381 let mut discarded_paths = Vec::new();
382 for path in selected {
383 if state.entries.remove(&path).is_some() {
384 discarded_paths.push(to_agent_path(&path));
385 }
386 }
387 persist_state(&state, "discard_staged", None).map_err(|err| HostlibError::Backend {
388 builtin: DISCARD_BUILTIN,
389 message: err,
390 })?;
391 emit_staged_update(&state);
392 guard.insert(session_id.to_string(), state);
393 Ok(DiscardResult { discarded_paths })
394}
395
396pub fn remove_session_state(session_id: &str, root: Option<&Path>) -> Result<(), HostlibError> {
403 validate_session_id(DISCARD_BUILTIN, session_id)?;
404 let mut guard = lock_sessions();
405 let state = match guard.remove(session_id) {
406 Some(state) => state,
407 None => load_state(session_id, root.map(normalize_logical)).map_err(|err| {
408 HostlibError::Backend {
409 builtin: DISCARD_BUILTIN,
410 message: err,
411 }
412 })?,
413 };
414 let dir = session_dir(&state.root, &state.session_id);
415 match stdfs::remove_dir_all(&dir) {
416 Ok(()) => Ok(()),
417 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
418 Err(err) => Err(HostlibError::Backend {
419 builtin: DISCARD_BUILTIN,
420 message: format!("remove staged session {}: {err}", dir.display()),
421 }),
422 }
423}
424
425pub(crate) fn read(
426 path: &Path,
427 explicit_session_id: Option<&str>,
428) -> Option<std::io::Result<Vec<u8>>> {
429 let session_id = active_session_id(explicit_session_id)?;
430 let mut guard = lock_sessions();
431 let state = state_for_locked(&mut guard, &session_id, None).ok()?;
432 let result = if state.mode == FsMode::Staged {
433 overlay_read(&state, path)
434 } else {
435 None
436 };
437 guard.insert(session_id, state);
438 result
439}
440
441pub(crate) fn read_to_string(
442 path: &Path,
443 explicit_session_id: Option<&str>,
444) -> Option<std::io::Result<String>> {
445 read(path, explicit_session_id).map(|result| {
446 result.and_then(|bytes| {
447 String::from_utf8(bytes).map_err(|err| {
448 std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string())
449 })
450 })
451 })
452}
453
454pub(crate) fn read_dir(
455 path: &Path,
456 explicit_session_id: Option<&str>,
457) -> Option<std::io::Result<Vec<OverlayDirEntry>>> {
458 let session_id = active_session_id(explicit_session_id)?;
459 let mut guard = lock_sessions();
460 let state = state_for_locked(&mut guard, &session_id, None).ok()?;
461 let result = if state.mode == FsMode::Staged {
462 Some(overlay_read_dir(&state, path))
463 } else {
464 None
465 };
466 guard.insert(session_id, state);
467 result
468}
469
470pub(crate) fn stage_write_or_none(
471 builtin: &'static str,
472 path: &Path,
473 bytes: &[u8],
474 create_parents: bool,
475 overwrite: bool,
476 explicit_session_id: Option<&str>,
477) -> Result<Option<WriteOutcome>, HostlibError> {
478 let Some(session_id) = active_session_id(explicit_session_id) else {
479 return Ok(None);
480 };
481 let mut guard = lock_sessions();
482 let mut state = state_for_locked(&mut guard, &session_id, None)?;
483 if state.mode != FsMode::Staged {
484 guard.insert(session_id, state);
485 return Ok(None);
486 }
487
488 let key = normalize_logical(path);
489 let existed = overlay_exists(&state, &key);
490 if existed && !overwrite {
491 guard.insert(session_id, state);
492 return Err(HostlibError::Backend {
493 builtin,
494 message: format!("`{}` exists and overwrite=false", key.display()),
495 });
496 }
497 if !create_parents && !parent_exists(&state, &key) {
498 guard.insert(session_id, state);
499 return Err(HostlibError::Backend {
500 builtin,
501 message: format!("parent directory for `{}` does not exist", key.display()),
502 });
503 }
504
505 let hash = write_body(&state, bytes).map_err(|err| HostlibError::Backend {
506 builtin,
507 message: err,
508 })?;
509 state.entries.insert(
510 key.clone(),
511 StagedEntry::Write {
512 body_hash: hash,
513 len: bytes.len() as u64,
514 created_at_ms: now_ms(),
515 },
516 );
517 persist_state(&state, "write", Some(&key)).map_err(|err| HostlibError::Backend {
518 builtin,
519 message: err,
520 })?;
521 emit_staged_update(&state);
522 guard.insert(session_id, state);
523 Ok(Some(WriteOutcome {
524 created: !existed,
525 bytes_written: bytes.len(),
526 }))
527}
528
529pub(crate) fn stage_delete_or_none(
530 builtin: &'static str,
531 path: &Path,
532 recursive: bool,
533 explicit_session_id: Option<&str>,
534) -> Result<Option<bool>, HostlibError> {
535 let Some(session_id) = active_session_id(explicit_session_id) else {
536 return Ok(None);
537 };
538 let mut guard = lock_sessions();
539 let mut state = state_for_locked(&mut guard, &session_id, None)?;
540 if state.mode != FsMode::Staged {
541 guard.insert(session_id, state);
542 return Ok(None);
543 }
544
545 let key = normalize_logical(path);
546 let staged_targets = staged_paths_under(&state, &key);
547 let disk_exists = key.exists();
548 if !disk_exists && staged_targets.is_empty() {
549 guard.insert(session_id, state);
550 return Ok(Some(false));
551 }
552
553 if !disk_exists {
554 for staged in staged_targets {
555 state.entries.remove(&staged);
556 }
557 } else {
558 validate_delete_shape(builtin, &key, recursive)?;
559 for staged in staged_targets {
560 state.entries.remove(&staged);
561 }
562 state.entries.insert(
563 key.clone(),
564 StagedEntry::Delete {
565 recursive,
566 created_at_ms: now_ms(),
567 },
568 );
569 }
570 persist_state(&state, "delete", Some(&key)).map_err(|err| HostlibError::Backend {
571 builtin,
572 message: err,
573 })?;
574 emit_staged_update(&state);
575 guard.insert(session_id, state);
576 Ok(Some(true))
577}
578
579#[derive(Clone, Debug)]
583pub struct SafeTextPatchOutcome {
584 pub result: SafeTextPatchResult,
586 pub current_hash: String,
588 pub after_hash: String,
590 pub created: bool,
592 pub bytes_written: usize,
594}
595
596#[derive(Clone, Copy, Debug, Eq, PartialEq)]
598pub enum SafeTextPatchResult {
599 Applied,
602 StaleBase,
605 NoOp,
608}
609
610impl SafeTextPatchResult {
611 fn as_str(self) -> &'static str {
612 match self {
613 Self::Applied => "applied",
614 Self::StaleBase => "stale_base",
615 Self::NoOp => "no_op",
616 }
617 }
618}
619
620fn hash_label(bytes: &[u8]) -> String {
624 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
625}
626
627pub fn safe_text_patch(
648 path: &Path,
649 content: &str,
650 expected_hash: Option<&str>,
651 session_id: Option<&str>,
652 create_parents: bool,
653 overwrite: bool,
654) -> Result<SafeTextPatchOutcome, HostlibError> {
655 let new_bytes = content.as_bytes();
656 let after_hash = hash_label(new_bytes);
657
658 if let Some(outcome) = safe_text_patch_staged(
659 path,
660 new_bytes,
661 expected_hash,
662 session_id,
663 create_parents,
664 overwrite,
665 &after_hash,
666 )? {
667 return Ok(outcome);
668 }
669
670 safe_text_patch_disk(path, new_bytes, expected_hash, create_parents, overwrite)
671}
672
673#[allow(clippy::too_many_arguments)]
679fn safe_text_patch_staged(
680 path: &Path,
681 new_bytes: &[u8],
682 expected_hash: Option<&str>,
683 session_id: Option<&str>,
684 create_parents: bool,
685 overwrite: bool,
686 after_hash: &str,
687) -> Result<Option<SafeTextPatchOutcome>, HostlibError> {
688 let Some(session) = active_session_id(session_id) else {
689 return Ok(None);
690 };
691 let mut guard = lock_sessions();
692 let mut state = state_for_locked(&mut guard, &session, None)?;
693 if state.mode != FsMode::Staged {
694 guard.insert(session, state);
695 return Ok(None);
696 }
697
698 let key = normalize_logical(path);
699 let (existing_bytes, existed) = match overlay_read(&state, path) {
700 Some(Ok(bytes)) => (bytes, true),
701 Some(Err(err)) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
702 Some(Err(err)) => {
703 guard.insert(session, state);
704 return Err(HostlibError::Backend {
705 builtin: SAFE_TEXT_PATCH_BUILTIN,
706 message: format!("read `{}`: {err}", path.display()),
707 });
708 }
709 None => match stdfs::read(path) {
710 Ok(bytes) => (bytes, true),
711 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (Vec::new(), false),
712 Err(err) => {
713 guard.insert(session, state);
714 return Err(HostlibError::Backend {
715 builtin: SAFE_TEXT_PATCH_BUILTIN,
716 message: format!("read `{}`: {err}", path.display()),
717 });
718 }
719 },
720 };
721 let current_hash = hash_label(&existing_bytes);
722
723 if let Some(expected) = expected_hash {
724 if expected != current_hash {
725 guard.insert(session, state);
726 return Ok(Some(SafeTextPatchOutcome {
727 result: SafeTextPatchResult::StaleBase,
728 current_hash,
729 after_hash: after_hash.to_string(),
730 created: false,
731 bytes_written: 0,
732 }));
733 }
734 }
735
736 if existed && existing_bytes == new_bytes {
737 guard.insert(session, state);
738 return Ok(Some(SafeTextPatchOutcome {
739 result: SafeTextPatchResult::NoOp,
740 current_hash,
741 after_hash: after_hash.to_string(),
742 created: false,
743 bytes_written: 0,
744 }));
745 }
746
747 let overlay_existed = overlay_exists(&state, &key);
748 if overlay_existed && !overwrite {
749 guard.insert(session, state);
750 return Err(HostlibError::Backend {
751 builtin: SAFE_TEXT_PATCH_BUILTIN,
752 message: format!("`{}` exists and overwrite=false", key.display()),
753 });
754 }
755 if !create_parents && !parent_exists(&state, &key) {
756 guard.insert(session, state);
757 return Err(HostlibError::Backend {
758 builtin: SAFE_TEXT_PATCH_BUILTIN,
759 message: format!("parent directory for `{}` does not exist", key.display()),
760 });
761 }
762
763 let body_hash = write_body(&state, new_bytes).map_err(|err| HostlibError::Backend {
764 builtin: SAFE_TEXT_PATCH_BUILTIN,
765 message: err,
766 })?;
767 state.entries.insert(
768 key.clone(),
769 StagedEntry::Write {
770 body_hash,
771 len: new_bytes.len() as u64,
772 created_at_ms: now_ms(),
773 },
774 );
775 persist_state(&state, "safe_text_patch", Some(&key)).map_err(|err| HostlibError::Backend {
776 builtin: SAFE_TEXT_PATCH_BUILTIN,
777 message: err,
778 })?;
779 emit_staged_update(&state);
780 guard.insert(session, state);
781
782 Ok(Some(SafeTextPatchOutcome {
783 result: SafeTextPatchResult::Applied,
784 current_hash,
785 after_hash: after_hash.to_string(),
786 created: !existed,
787 bytes_written: new_bytes.len(),
788 }))
789}
790
791fn safe_text_patch_disk(
795 path: &Path,
796 new_bytes: &[u8],
797 expected_hash: Option<&str>,
798 create_parents: bool,
799 overwrite: bool,
800) -> Result<SafeTextPatchOutcome, HostlibError> {
801 let options = harn_vm::conditional_replace::ConditionalReplaceOptions {
802 expected_sha256: expected_hash.map(str::to_string),
803 create: true,
804 overwrite,
805 create_parents,
806 durability: harn_vm::atomic_io::AtomicWriteDurability::Flush,
807 };
808 let receipt = harn_vm::conditional_replace::conditional_replace_with_hook(
809 path,
810 new_bytes,
811 &options,
812 || crate::fs_snapshot::auto_capture_for_write(SAFE_TEXT_PATCH_BUILTIN, path),
813 )
814 .map_err(|err| HostlibError::Backend {
815 builtin: SAFE_TEXT_PATCH_BUILTIN,
816 message: format!("replace `{}`: {err}", path.display()),
817 })?;
818 Ok(SafeTextPatchOutcome {
819 result: match receipt.status {
820 harn_vm::conditional_replace::ConditionalReplaceStatus::Created
821 | harn_vm::conditional_replace::ConditionalReplaceStatus::Replaced => {
822 SafeTextPatchResult::Applied
823 }
824 harn_vm::conditional_replace::ConditionalReplaceStatus::NoOp => {
825 SafeTextPatchResult::NoOp
826 }
827 harn_vm::conditional_replace::ConditionalReplaceStatus::Stale => {
828 SafeTextPatchResult::StaleBase
829 }
830 },
831 current_hash: receipt.before_sha256,
832 after_hash: receipt.after_sha256,
833 created: receipt.status == harn_vm::conditional_replace::ConditionalReplaceStatus::Created,
834 bytes_written: receipt.bytes_written,
835 })
836}
837
838fn read_existing(
843 builtin: &'static str,
844 path: &Path,
845 session_id: Option<&str>,
846) -> Result<(Vec<u8>, bool), HostlibError> {
847 if let Some(result) = read(path, session_id) {
848 return match result {
849 Ok(bytes) => Ok((bytes, true)),
850 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
851 Err(err) => Err(HostlibError::Backend {
852 builtin,
853 message: format!("read `{}`: {err}", path.display()),
854 }),
855 };
856 }
857 match stdfs::read(path) {
858 Ok(bytes) => Ok((bytes, true)),
859 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((Vec::new(), false)),
860 Err(err) => Err(HostlibError::Backend {
861 builtin,
862 message: format!("read `{}`: {err}", path.display()),
863 }),
864 }
865}
866
867fn read_text_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
868 let raw = dict_arg(READ_TEXT_BUILTIN, args)?;
869 let dict = raw.as_ref();
870 let path_str = require_string(READ_TEXT_BUILTIN, dict, "path")?;
871 let session_id = optional_string(READ_TEXT_BUILTIN, dict, "session_id")?;
872 let path = resolve_host_path(&path_str);
873 enforce_path_scope(READ_TEXT_BUILTIN, &path, FsAccess::Read)?;
874
875 let (bytes, existed) = read_existing(READ_TEXT_BUILTIN, &path, session_id.as_deref())?;
876 let hash = hash_label(&bytes);
877 let content = match std::str::from_utf8(&bytes) {
878 Ok(s) => s.to_string(),
879 Err(err) => {
880 return Err(HostlibError::Backend {
881 builtin: READ_TEXT_BUILTIN,
882 message: format!("`{path_str}` is not valid UTF-8: {err}"),
883 });
884 }
885 };
886 let bytes_len = bytes.len() as i64;
887 Ok(build_dict([
888 ("path", str_value(&path_str)),
889 ("content", str_value(&content)),
890 ("sha256", str_value(&hash)),
891 ("size", VmValue::Int(bytes_len)),
892 ("exists", VmValue::Bool(existed)),
893 ]))
894}
895
896fn safe_text_patch_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
897 let raw = dict_arg(SAFE_TEXT_PATCH_BUILTIN, args)?;
898 let dict = raw.as_ref();
899
900 let path_str = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "path")?;
901 let content = require_string(SAFE_TEXT_PATCH_BUILTIN, dict, "content")?;
902 let expected_hash = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "expected_hash")?;
903 let session_id = optional_string(SAFE_TEXT_PATCH_BUILTIN, dict, "session_id")?;
904 let create_parents = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "create_parents", true)?;
905 let overwrite = optional_bool(SAFE_TEXT_PATCH_BUILTIN, dict, "overwrite", true)?;
906
907 let path = resolve_host_path(&path_str);
908 enforce_path_scope(SAFE_TEXT_PATCH_BUILTIN, &path, FsAccess::Write)?;
909 let outcome = safe_text_patch(
910 &path,
911 &content,
912 expected_hash.as_deref(),
913 session_id.as_deref(),
914 create_parents,
915 overwrite,
916 )?;
917
918 let entries: Vec<(&'static str, VmValue)> = vec![
919 ("path", str_value(&path_str)),
920 ("result", str_value(outcome.result.as_str())),
921 (
922 "applied",
923 VmValue::Bool(outcome.result == SafeTextPatchResult::Applied),
924 ),
925 (
926 "stale_base",
927 VmValue::Bool(outcome.result == SafeTextPatchResult::StaleBase),
928 ),
929 ("current_hash", str_value(&outcome.current_hash)),
930 ("before_sha256", str_value(&outcome.current_hash)),
931 ("after_sha256", str_value(&outcome.after_hash)),
932 ("created", VmValue::Bool(outcome.created)),
933 ("bytes_written", VmValue::Int(outcome.bytes_written as i64)),
934 (
935 "expected_hash",
936 match expected_hash.as_deref() {
937 Some(hash) => str_value(hash),
938 None => VmValue::Nil,
939 },
940 ),
941 ];
942 Ok(build_dict(entries))
943}
944
945fn emit_safe_text_patch_result_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
946 let raw = dict_arg(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, args)?;
947 let dict = raw.as_ref();
948
949 let path = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "path")?;
950 let result = require_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "result")?;
951 let hunks_count = optional_int(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "hunks_count", 0)?;
952 let bytes_written = optional_int(
953 EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
954 dict,
955 "bytes_written",
956 0,
957 )?;
958 let failed_hunk_index = match dict.get("failed_hunk_index") {
959 None | Some(VmValue::Nil) => None,
960 Some(VmValue::Int(n)) if *n >= 0 => Some(*n as usize),
961 Some(other) => {
962 return Err(HostlibError::InvalidParameter {
963 builtin: EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN,
964 param: "failed_hunk_index",
965 message: format!("expected non-negative integer, got {}", other.type_name()),
966 });
967 }
968 };
969 let session_id = optional_string(EMIT_SAFE_TEXT_PATCH_RESULT_BUILTIN, dict, "session_id")?
970 .or_else(harn_vm::agent_sessions::current_session_id);
971
972 if let Some(session_id) = session_id.filter(|s| !s.trim().is_empty()) {
973 harn_vm::agent_events::emit_event(&AgentEvent::SafeTextPatchResult {
974 session_id,
975 path,
976 result,
977 hunks_count: hunks_count.max(0) as usize,
978 bytes_written: bytes_written.max(0) as u64,
979 failed_hunk_index,
980 });
981 Ok(VmValue::Bool(true))
982 } else {
983 Ok(VmValue::Bool(false))
987 }
988}
989
990fn set_mode_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
991 let raw = dict_arg(SET_MODE_BUILTIN, args)?;
992 let dict = raw.as_ref();
993 let session_id = require_string(SET_MODE_BUILTIN, dict, "session_id")?;
994 let mode = FsMode::parse(
995 SET_MODE_BUILTIN,
996 &require_string(SET_MODE_BUILTIN, dict, "mode")?,
997 )?;
998 let root =
999 optional_string(SET_MODE_BUILTIN, dict, "root")?.map(|path| resolve_host_path(&path));
1000 let result = set_mode(&session_id, mode, root.as_deref())?;
1001 Ok(build_dict([(
1002 "previous_mode",
1003 str_value(result.previous_mode.as_str()),
1004 )]))
1005}
1006
1007fn staged_status_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1008 let raw = dict_arg(STATUS_BUILTIN, args)?;
1009 let session_id = require_string(STATUS_BUILTIN, raw.as_ref(), "session_id")?;
1010 Ok(status_to_value(staged_status(&session_id)?))
1011}
1012
1013fn commit_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1014 let raw = dict_arg(COMMIT_BUILTIN, args)?;
1015 let dict = raw.as_ref();
1016 let session_id = require_string(COMMIT_BUILTIN, dict, "session_id")?;
1017 let paths = optional_string_list(COMMIT_BUILTIN, dict, "paths")?;
1018 Ok(commit_result_to_value(commit_staged(&session_id, &paths)?))
1019}
1020
1021fn discard_staged_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
1022 let raw = dict_arg(DISCARD_BUILTIN, args)?;
1023 let dict = raw.as_ref();
1024 let session_id = require_string(DISCARD_BUILTIN, dict, "session_id")?;
1025 let paths = optional_string_list(DISCARD_BUILTIN, dict, "paths")?;
1026 Ok(discard_result_to_value(discard_staged(
1027 &session_id,
1028 &paths,
1029 )?))
1030}
1031
1032fn state_for_locked(
1033 guard: &mut BTreeMap<String, SessionState>,
1034 session_id: &str,
1035 root: Option<PathBuf>,
1036) -> Result<SessionState, HostlibError> {
1037 if let Some(existing) = guard.get(session_id) {
1038 let mut state = existing.clone();
1039 if let Some(root) = root {
1040 if state.entries.is_empty() {
1041 state.root = root;
1042 }
1043 }
1044 return Ok(state);
1045 }
1046 let state = load_state(session_id, root).map_err(|err| HostlibError::Backend {
1047 builtin: SET_MODE_BUILTIN,
1048 message: err,
1049 })?;
1050 Ok(state)
1051}
1052
1053fn load_state(session_id: &str, root: Option<PathBuf>) -> Result<SessionState, String> {
1054 let root = root.unwrap_or_else(default_root);
1055 let manifest_path = manifest_path(&root, session_id);
1056 if manifest_path.exists() {
1057 let text = stdfs::read_to_string(&manifest_path)
1058 .map_err(|err| format!("read {}: {err}", manifest_path.display()))?;
1059 let manifest: Manifest = serde_json::from_str(&text)
1060 .map_err(|err| format!("parse {}: {err}", manifest_path.display()))?;
1061 if manifest.version != MANIFEST_VERSION {
1062 return Err(format!(
1063 "unsupported staged fs manifest version {} in {}",
1064 manifest.version,
1065 manifest_path.display()
1066 ));
1067 }
1068 if manifest.session_id != session_id {
1069 return Err(format!(
1070 "staged fs manifest session id mismatch in {}",
1071 manifest_path.display()
1072 ));
1073 }
1074 return Ok(SessionState {
1075 session_id: manifest.session_id,
1076 mode: manifest.mode,
1077 root: normalize_logical(Path::new(&manifest.root)),
1078 entries: manifest
1079 .entries
1080 .into_iter()
1081 .map(|(path, entry)| (normalize_logical(Path::new(&path)), entry))
1082 .collect(),
1083 });
1084 }
1085 Ok(SessionState {
1086 session_id: session_id.to_string(),
1087 mode: FsMode::Immediate,
1088 root,
1089 entries: BTreeMap::new(),
1090 })
1091}
1092
1093fn persist_state(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1094 let dir = session_dir(&state.root, &state.session_id);
1095 stdfs::create_dir_all(dir.join("bodies"))
1096 .map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1097 let manifest = Manifest {
1098 version: MANIFEST_VERSION,
1099 session_id: state.session_id.clone(),
1100 mode: state.mode,
1101 root: state.root.to_string_lossy().into_owned(),
1102 entries: state
1103 .entries
1104 .iter()
1105 .map(|(path, entry)| (path.to_string_lossy().into_owned(), entry.clone()))
1106 .collect(),
1107 };
1108 let bytes = serde_json::to_vec_pretty(&manifest)
1109 .map_err(|err| format!("serialize staged manifest: {err}"))?;
1110 atomic_write(&manifest_path(&state.root, &state.session_id), &bytes)?;
1111 append_journal(state, op, path)?;
1112 prune_unreferenced_bodies(state);
1113 Ok(())
1114}
1115
1116fn append_journal(state: &SessionState, op: &str, path: Option<&Path>) -> Result<(), String> {
1117 let dir = session_dir(&state.root, &state.session_id);
1118 stdfs::create_dir_all(&dir).map_err(|err| format!("mkdir {}: {err}", dir.display()))?;
1119 let line = serde_json::to_string(&serde_json::json!({
1120 "ts_ms": now_ms(),
1121 "op": op,
1122 "path": path.map(|path| path.to_string_lossy().into_owned()), "pending_count": state.entries.len(),
1124 }))
1125 .map_err(|err| format!("serialize staged journal: {err}"))?;
1126 let mut file = stdfs::OpenOptions::new()
1127 .create(true)
1128 .append(true)
1129 .open(dir.join("journal.jsonl"))
1130 .map_err(|err| format!("open staged journal: {err}"))?;
1131 writeln!(file, "{line}").map_err(|err| format!("write staged journal: {err}"))
1132}
1133
1134fn write_body(state: &SessionState, bytes: &[u8]) -> Result<String, String> {
1135 let hash = hex::encode(Sha256::digest(bytes));
1136 let path = session_dir(&state.root, &state.session_id)
1137 .join("bodies")
1138 .join(&hash);
1139 if !path.exists() {
1140 atomic_write(&path, bytes)?;
1141 }
1142 Ok(hash)
1143}
1144
1145fn read_body(state: &SessionState, hash: &str) -> std::io::Result<Vec<u8>> {
1146 stdfs::read(
1147 session_dir(&state.root, &state.session_id)
1148 .join("bodies")
1149 .join(hash),
1150 )
1151}
1152
1153fn prune_unreferenced_bodies(state: &SessionState) {
1154 let live: BTreeSet<String> = state
1155 .entries
1156 .values()
1157 .filter_map(|entry| match entry {
1158 StagedEntry::Write { body_hash, .. } => Some(body_hash.clone()),
1159 StagedEntry::Delete { .. } => None,
1160 })
1161 .collect();
1162 let body_dir = session_dir(&state.root, &state.session_id).join("bodies");
1163 let Ok(entries) = stdfs::read_dir(&body_dir) else {
1164 return;
1165 };
1166 for entry in entries.flatten() {
1167 let name = entry.file_name().to_string_lossy().into_owned();
1168 if !live.contains(&name) {
1169 let _ = stdfs::remove_file(entry.path());
1170 }
1171 }
1172}
1173
1174fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
1175 harn_vm::atomic_io::atomic_write(path, bytes)
1176 .map_err(|error| format!("write {}: {error}", path.display()))
1177}
1178
1179fn commit_entry(state: &SessionState, path: &Path, entry: &StagedEntry) -> Result<(), String> {
1180 match entry {
1181 StagedEntry::Write { body_hash, .. } => {
1182 let bytes = read_body(state, body_hash)
1183 .map_err(|err| format!("read staged body for {}: {err}", path.display()))?;
1184 atomic_write(path, &bytes)
1185 }
1186 StagedEntry::Delete { recursive, .. } => match stdfs::symlink_metadata(path) {
1187 Ok(metadata) if metadata.is_dir() => {
1188 if *recursive {
1189 stdfs::remove_dir_all(path)
1190 .map_err(|err| format!("remove_dir_all {}: {err}", path.display()))
1191 } else {
1192 stdfs::remove_dir(path)
1193 .map_err(|err| format!("remove_dir {}: {err}", path.display()))
1194 }
1195 }
1196 Ok(_) => stdfs::remove_file(path)
1197 .map_err(|err| format!("remove_file {}: {err}", path.display())),
1198 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
1199 Err(err) => Err(format!("stat {}: {err}", path.display())),
1200 },
1201 }
1202}
1203
1204fn overlay_read(state: &SessionState, path: &Path) -> Option<std::io::Result<Vec<u8>>> {
1205 let key = normalize_logical(path);
1206 if let Some(entry) = state.entries.get(&key) {
1207 return Some(match entry {
1208 StagedEntry::Write { body_hash, .. } => read_body(state, body_hash),
1209 StagedEntry::Delete { .. } => Err(not_found(&key)),
1210 });
1211 }
1212 if deleted_ancestor(state, &key) {
1213 return Some(Err(not_found(&key)));
1214 }
1215 None
1216}
1217
1218fn overlay_read_dir(state: &SessionState, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
1219 let dir_key = normalize_logical(path);
1220 if matches!(state.entries.get(&dir_key), Some(StagedEntry::Write { .. }))
1221 || deleted_ancestor(state, &dir_key)
1222 || matches!(
1223 state.entries.get(&dir_key),
1224 Some(StagedEntry::Delete { .. })
1225 )
1226 {
1227 return Err(not_found(&dir_key));
1228 }
1229 if !path.exists() && !has_staged_descendant(state, &dir_key) {
1230 return Err(not_found(&dir_key));
1231 }
1232
1233 let mut entries: BTreeMap<String, OverlayDirEntry> = BTreeMap::new();
1234 if path.exists() {
1235 for entry in stdfs::read_dir(path)? {
1236 let entry = entry?;
1237 let name = entry.file_name().to_string_lossy().into_owned();
1238 let file_type = entry.file_type().ok();
1239 let metadata = entry.metadata().ok();
1240 entries.insert(
1241 name.clone(),
1242 OverlayDirEntry {
1243 name,
1244 is_dir: file_type.is_some_and(|ty| ty.is_dir()),
1245 is_symlink: file_type.is_some_and(|ty| ty.is_symlink()),
1246 size: metadata.map(|m| m.len()).unwrap_or(0),
1247 },
1248 );
1249 }
1250 }
1251
1252 for (path, entry) in &state.entries {
1253 let Some(name) = overlay_child_name(path, &dir_key) else {
1254 continue;
1255 };
1256 match entry {
1257 StagedEntry::Write { len, .. } => {
1258 let is_dir = path.parent() != Some(dir_key.as_path());
1259 entries.insert(
1260 name.clone(),
1261 OverlayDirEntry {
1262 name,
1263 is_dir,
1264 is_symlink: false,
1265 size: if is_dir { 0 } else { *len },
1266 },
1267 );
1268 }
1269 StagedEntry::Delete { .. } => {
1270 if path.parent() == Some(dir_key.as_path()) {
1271 entries.remove(&name);
1272 }
1273 }
1274 }
1275 }
1276
1277 Ok(entries.into_values().collect())
1278}
1279
1280fn overlay_child_name(path: &Path, dir: &Path) -> Option<String> {
1281 let suffix = path.strip_prefix(dir).ok()?;
1282 let mut components = suffix.components();
1283 let first = components.next()?;
1284 match first {
1285 Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1286 _ => None,
1287 }
1288}
1289
1290fn overlay_exists(state: &SessionState, path: &Path) -> bool {
1291 if let Some(entry) = state.entries.get(path) {
1292 return matches!(entry, StagedEntry::Write { .. });
1293 }
1294 if deleted_ancestor(state, path) {
1295 return false;
1296 }
1297 if has_staged_descendant(state, path) {
1298 return true;
1299 }
1300 path.exists()
1301}
1302
1303fn parent_exists(state: &SessionState, path: &Path) -> bool {
1304 let Some(parent) = path.parent() else {
1305 return true;
1306 };
1307 if parent.as_os_str().is_empty() {
1308 return true;
1309 }
1310 if let Some(entry) = state.entries.get(parent) {
1311 return !matches!(entry, StagedEntry::Delete { .. });
1312 }
1313 if deleted_ancestor(state, parent) {
1314 return false;
1315 }
1316 if has_staged_descendant(state, parent) {
1317 return true;
1318 }
1319 parent.is_dir()
1320}
1321
1322fn deleted_ancestor(state: &SessionState, path: &Path) -> bool {
1323 state.entries.iter().any(|(candidate, entry)| {
1324 matches!(entry, StagedEntry::Delete { .. })
1325 && path != candidate.as_path()
1326 && path.starts_with(candidate)
1327 })
1328}
1329
1330fn has_staged_descendant(state: &SessionState, path: &Path) -> bool {
1331 state.entries.iter().any(|(candidate, entry)| {
1332 matches!(entry, StagedEntry::Write { .. })
1333 && candidate != path
1334 && candidate.starts_with(path)
1335 })
1336}
1337
1338fn staged_paths_under(state: &SessionState, path: &Path) -> Vec<PathBuf> {
1339 state
1340 .entries
1341 .keys()
1342 .filter(|candidate| *candidate == path || candidate.starts_with(path))
1343 .cloned()
1344 .collect()
1345}
1346
1347fn validate_delete_shape(
1348 builtin: &'static str,
1349 path: &Path,
1350 recursive: bool,
1351) -> Result<(), HostlibError> {
1352 let Ok(metadata) = stdfs::symlink_metadata(path) else {
1353 return Ok(());
1354 };
1355 if metadata.is_dir() && !recursive {
1356 let mut entries = stdfs::read_dir(path).map_err(|err| HostlibError::Backend {
1357 builtin,
1358 message: format!("read_dir `{}`: {err}", path.display()),
1359 })?;
1360 if entries.next().is_some() {
1361 return Err(HostlibError::Backend {
1362 builtin,
1363 message: format!(
1364 "remove_dir `{}` (pass recursive=true to delete non-empty dirs): directory not empty",
1365 path.display()
1366 ),
1367 });
1368 }
1369 }
1370 Ok(())
1371}
1372
1373fn status_from_state(state: &SessionState) -> StagedStatus {
1374 let now = now_ms();
1375 let mut pending_writes = Vec::new();
1376 let mut total_bytes_pending = 0u64;
1377 let mut oldest = None;
1378 for (path, entry) in &state.entries {
1379 total_bytes_pending = total_bytes_pending.saturating_add(entry.body_len());
1380 oldest = Some(oldest.map_or(entry.created_at_ms(), |old: i64| {
1381 old.min(entry.created_at_ms())
1382 }));
1383 let (kind, bytes_added, bytes_removed) = match entry {
1384 StagedEntry::Write { len, .. } => ("write", *len, disk_size(path).unwrap_or(0)),
1385 StagedEntry::Delete { .. } => ("delete", 0, disk_size(path).unwrap_or(0)),
1386 };
1387 pending_writes.push(PendingWrite {
1388 path: to_agent_path(path),
1389 kind,
1390 bytes_added,
1391 bytes_removed,
1392 });
1393 }
1394 StagedStatus {
1395 pending_writes,
1396 total_bytes_pending,
1397 oldest_pending_age_ms: oldest.map(|old| now.saturating_sub(old)).unwrap_or(0),
1398 }
1399}
1400
1401fn disk_size(path: &Path) -> Option<u64> {
1402 let metadata = stdfs::symlink_metadata(path).ok()?;
1403 if metadata.is_file() {
1404 return Some(metadata.len());
1405 }
1406 if metadata.is_dir() {
1407 let mut total = 0u64;
1408 for entry in walkdir::WalkDir::new(path)
1409 .into_iter()
1410 .filter_map(Result::ok)
1411 {
1412 if let Ok(metadata) = entry.metadata() {
1413 if metadata.is_file() {
1414 total = total.saturating_add(metadata.len());
1415 }
1416 }
1417 }
1418 return Some(total);
1419 }
1420 Some(metadata.len())
1421}
1422
1423fn selected_paths(state: &SessionState, paths: &[String]) -> Vec<PathBuf> {
1424 if paths.is_empty() {
1425 return state.entries.keys().cloned().collect();
1426 }
1427 let selected: BTreeSet<PathBuf> = paths
1428 .iter()
1429 .map(|path| normalize_logical(Path::new(path)))
1430 .collect();
1431 state
1432 .entries
1433 .keys()
1434 .filter(|path| selected.contains(*path))
1435 .cloned()
1436 .collect()
1437}
1438
1439fn now_ms() -> i64 {
1440 std::time::SystemTime::now()
1441 .duration_since(std::time::UNIX_EPOCH)
1442 .map(|duration| duration.as_millis() as i64)
1443 .unwrap_or(0)
1444}
1445
1446fn emit_staged_update(state: &SessionState) {
1447 let status = status_from_state(state);
1448 harn_vm::agent_events::emit_event(&AgentEvent::StagedWritesPending {
1449 session_id: state.session_id.clone(),
1450 pending_count: status.pending_writes.len(),
1451 total_bytes: status.total_bytes_pending,
1452 });
1453}