Skip to content

Commit d3db6d8

Browse files
committed
fix(snapshot): preserve counters on resume
1 parent 83cbac6 commit d3db6d8

4 files changed

Lines changed: 45 additions & 6 deletions

File tree

crates/bashkit/src/interpreter/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,10 +1946,10 @@ impl Interpreter {
19461946
&self.counters
19471947
}
19481948

1949-
/// Restore session-level counters from a snapshot.
1949+
/// Merge session-level counters from a snapshot without lowering live usage.
19501950
pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
1951-
self.counters.session_commands = session_commands;
1952-
self.counters.session_exec_calls = session_exec_calls;
1951+
self.counters.session_commands = self.counters.session_commands.max(session_commands);
1952+
self.counters.session_exec_calls = self.counters.session_exec_calls.max(session_exec_calls);
19531953
}
19541954

19551955
/// Set an output callback for streaming output during execution.

crates/bashkit/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,10 +1159,11 @@ impl Bash {
11591159
(c.session_commands, c.session_exec_calls)
11601160
}
11611161

1162-
/// Restore session-level counters to resume a session across Bash instances.
1162+
/// Merge session-level counters to resume a session across Bash instances.
11631163
///
11641164
/// This is used by external tool hosts to persist cumulative session counters
1165-
/// across fresh Bash instances created per tool call.
1165+
/// across fresh Bash instances created per tool call. Counters are monotonic:
1166+
/// restoring lower values never reduces already-consumed session budget.
11661167
pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
11671168
self.interpreter
11681169
.restore_session_counters(session_commands, session_exec_calls);

crates/bashkit/src/snapshot.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Decision: Snapshot format uses serde_json for Phase 1 (debuggable, human-readable).
22
// Phase 2 can add bincode/postcard for compactness.
33
// VFS contents are included by default; SnapshotOptions can opt out for shell-only restores.
4-
// Session counters are serialized for observability; restore paths do not trust them.
4+
// Session counters are serialized and restored monotonically so snapshot/resume cannot reset budgets.
55

66
//! Snapshot/resume — serialize interpreter state between `exec()` calls.
77
//!
@@ -335,6 +335,11 @@ impl crate::Bash {
335335
// Shell state cannot fail past validation, and the VFS has already
336336
// been restored atomically (or rejected) above.
337337
self.interpreter.restore_shell_state(&snap.shell);
338+
// Session counters are part of session accounting. Merge them
339+
// monotonically: authenticated snapshot/resume carries used budget
340+
// forward, while tampered unkeyed bytes cannot lower live counters.
341+
self.interpreter
342+
.restore_session_counters(snap.session_commands, snap.session_exec_calls);
338343
Ok(())
339344
}
340345

crates/bashkit/tests/integration/snapshot_tests.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,39 @@ async fn snapshot_restore_does_not_reset_session_exec_limit_with_tampered_counte
567567
);
568568
}
569569

570+
#[tokio::test]
571+
async fn keyed_snapshot_restore_carries_session_exec_budget_forward() {
572+
let key = b"session-budget-hmac-key";
573+
let session_limits = SessionLimits::new().max_exec_calls(2);
574+
let mut bash = Bash::builder()
575+
.session_limits(session_limits.clone())
576+
.build();
577+
bash.exec("echo first").await.unwrap();
578+
let bytes = bash.snapshot_to_bytes_keyed(key).unwrap();
579+
580+
let mut restored = Bash::builder().session_limits(session_limits).build();
581+
restored.restore_snapshot_keyed(&bytes, key).unwrap();
582+
assert_eq!(restored.session_counters().1, 1);
583+
584+
restored.exec("echo second").await.unwrap();
585+
let third = restored.exec("echo third").await;
586+
assert!(
587+
third.is_err(),
588+
"authenticated snapshot resume must not grant a fresh exec-call budget"
589+
);
590+
}
591+
592+
#[tokio::test]
593+
async fn from_snapshot_keyed_restores_session_counters() {
594+
let key = b"session-counter-hmac-key";
595+
let mut bash = Bash::new();
596+
bash.exec("echo first").await.unwrap();
597+
let bytes = bash.snapshot_to_bytes_keyed(key).unwrap();
598+
599+
let restored = Bash::from_snapshot_keyed(&bytes, key).unwrap();
600+
assert_eq!(restored.session_counters(), bash.session_counters());
601+
}
602+
570603
#[tokio::test]
571604
async fn snapshot_restore_rejects_tampered_shell_state_that_exceeds_memory_limits() {
572605
let mut src = Bash::new();

0 commit comments

Comments
 (0)