Skip to content

Commit 8aa0e4e

Browse files
committed
feat: add max_memory to Rust BashBuilder, propagate to JS + Python
Adds `BashBuilder::max_memory(bytes)` convenience method in the core crate that sets `max_total_variable_bytes` and clamps `max_function_body_bytes`. JS bindings now delegate to this instead of a local helper. Python bindings (`Bash` and `BashTool`) gain `max_memory` parameter with the same semantics. Includes Rust-level unit test for the builder method.
1 parent 9531234 commit 8aa0e4e

File tree

3 files changed

+71
-18
lines changed

3 files changed

+71
-18
lines changed

crates/bashkit-js/src/lib.rs

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
1616
use bashkit::tool::VERSION;
1717
use bashkit::{
18-
Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, ExtFunctionResult, MemoryLimits,
19-
MontyObject, PythonExternalFnHandler, PythonLimits, ScriptedTool as RustScriptedTool, Tool,
20-
ToolArgs, ToolDef, ToolRequest,
18+
Bash as RustBash, BashTool as RustBashTool, ExecutionLimits, ExtFunctionResult, MontyObject,
19+
PythonExternalFnHandler, PythonLimits, ScriptedTool as RustScriptedTool, Tool, ToolArgs,
20+
ToolDef, ToolRequest,
2121
};
2222
use napi_derive::napi;
2323
use std::collections::HashMap;
@@ -1158,20 +1158,6 @@ impl ScriptedTool {
11581158
// Helpers
11591159
// ============================================================================
11601160

1161-
/// Convert a single `maxMemory` byte budget into granular `MemoryLimits`.
1162-
///
1163-
/// The budget is applied as `max_total_variable_bytes` (the dominant vector)
1164-
/// and `max_function_body_bytes` is set to min(budget, default). Other
1165-
/// sub-limits (variable count, array entries, function count) stay at their
1166-
/// defaults since they are count-based, not byte-based.
1167-
fn build_memory_limits(max_bytes: f64) -> MemoryLimits {
1168-
let bytes = max_bytes as usize;
1169-
let defaults = MemoryLimits::default();
1170-
MemoryLimits::new()
1171-
.max_total_variable_bytes(bytes)
1172-
.max_function_body_bytes(bytes.min(defaults.max_function_body_bytes))
1173-
}
1174-
11751161
#[allow(clippy::too_many_arguments)]
11761162
fn build_bash(
11771163
username: Option<&str>,
@@ -1203,7 +1189,7 @@ fn build_bash(
12031189
builder = builder.limits(limits);
12041190

12051191
if let Some(max_mem) = max_memory {
1206-
builder = builder.memory_limits(build_memory_limits(max_mem));
1192+
builder = builder.max_memory(max_mem as usize);
12071193
}
12081194

12091195
// Mount files into the virtual filesystem

crates/bashkit-python/src/lib.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ pub struct PyBash {
662662
real_mounts: Vec<RealMountConfig>,
663663
max_commands: Option<u64>,
664664
max_loop_iterations: Option<u64>,
665+
max_memory: Option<u64>,
665666
}
666667

667668
#[pymethods]
@@ -672,6 +673,7 @@ impl PyBash {
672673
hostname=None,
673674
max_commands=None,
674675
max_loop_iterations=None,
676+
max_memory=None,
675677
python=false,
676678
external_functions=None,
677679
external_handler=None,
@@ -689,6 +691,7 @@ impl PyBash {
689691
hostname: Option<String>,
690692
max_commands: Option<u64>,
691693
max_loop_iterations: Option<u64>,
694+
max_memory: Option<u64>,
692695
python: bool,
693696
external_functions: Option<Vec<String>>,
694697
external_handler: Option<Py<PyAny>>,
@@ -717,6 +720,10 @@ impl PyBash {
717720
}
718721
builder = builder.limits(limits);
719722

723+
if let Some(mm) = max_memory {
724+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
725+
}
726+
720727
let (mounted_text_files, real_mounts) = parse_mount_configs(
721728
mount_text,
722729
mount_readonly_text,
@@ -786,6 +793,7 @@ impl PyBash {
786793
real_mounts,
787794
max_commands,
788795
max_loop_iterations,
796+
max_memory,
789797
})
790798
}
791799

@@ -935,6 +943,7 @@ impl PyBash {
935943
let hostname = self.hostname.clone();
936944
let max_commands = self.max_commands;
937945
let max_loop_iterations = self.max_loop_iterations;
946+
let max_memory = self.max_memory;
938947
let python = self.python;
939948
let external_functions = self.external_functions.clone();
940949
let mounted_text_files = self.mounted_text_files.clone();
@@ -961,6 +970,9 @@ impl PyBash {
961970
limits = limits.max_loop_iterations(usize::try_from(mli).unwrap_or(usize::MAX));
962971
}
963972
builder = builder.limits(limits);
973+
if let Some(mm) = max_memory {
974+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
975+
}
964976
builder = apply_python_config(builder, python, external_functions, handler_clone);
965977
builder = apply_fs_config(builder, &mounted_text_files, &real_mounts);
966978
*bash = builder.build();
@@ -1067,6 +1079,7 @@ pub struct BashTool {
10671079
real_mounts: Vec<RealMountConfig>,
10681080
max_commands: Option<u64>,
10691081
max_loop_iterations: Option<u64>,
1082+
max_memory: Option<u64>,
10701083
}
10711084

10721085
impl BashTool {
@@ -1101,6 +1114,7 @@ impl BashTool {
11011114
hostname=None,
11021115
max_commands=None,
11031116
max_loop_iterations=None,
1117+
max_memory=None,
11041118
mount_text=None,
11051119
mount_readonly_text=None,
11061120
mount_real_readonly=None,
@@ -1113,6 +1127,7 @@ impl BashTool {
11131127
hostname: Option<String>,
11141128
max_commands: Option<u64>,
11151129
max_loop_iterations: Option<u64>,
1130+
max_memory: Option<u64>,
11161131
mount_text: Option<Vec<(String, String)>>,
11171132
mount_readonly_text: Option<Vec<(String, String)>>,
11181133
mount_real_readonly: Option<Vec<String>>,
@@ -1138,6 +1153,10 @@ impl BashTool {
11381153
}
11391154
builder = builder.limits(limits);
11401155

1156+
if let Some(mm) = max_memory {
1157+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
1158+
}
1159+
11411160
let (mounted_text_files, real_mounts) = parse_mount_configs(
11421161
mount_text,
11431162
mount_readonly_text,
@@ -1163,6 +1182,7 @@ impl BashTool {
11631182
real_mounts,
11641183
max_commands,
11651184
max_loop_iterations,
1185+
max_memory,
11661186
})
11671187
}
11681188

@@ -1295,6 +1315,7 @@ impl BashTool {
12951315
let real_mounts = self.real_mounts.clone();
12961316
let max_commands = self.max_commands;
12971317
let max_loop_iterations = self.max_loop_iterations;
1318+
let max_memory = self.max_memory;
12981319
let cancelled = self.cancelled.clone();
12991320

13001321
py.detach(|| {
@@ -1315,6 +1336,9 @@ impl BashTool {
13151336
limits = limits.max_loop_iterations(usize::try_from(mli).unwrap_or(usize::MAX));
13161337
}
13171338
builder = builder.limits(limits);
1339+
if let Some(mm) = max_memory {
1340+
builder = builder.max_memory(usize::try_from(mm).unwrap_or(usize::MAX));
1341+
}
13181342
builder = apply_fs_config(builder, &mounted_text_files, &real_mounts);
13191343
*bash = builder.build();
13201344
// Swap the cancellation token to the new interpreter's token so

crates/bashkit/src/lib.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,30 @@ impl BashBuilder {
11001100
self
11011101
}
11021102

1103+
/// Cap total interpreter memory to `bytes`.
1104+
///
1105+
/// Convenience wrapper over [`memory_limits`](Self::memory_limits) that
1106+
/// sets `max_total_variable_bytes` to `bytes` and clamps
1107+
/// `max_function_body_bytes` to `min(bytes, default)`. Count-based
1108+
/// sub-limits (variable count, array entries, function count) stay at
1109+
/// their defaults.
1110+
///
1111+
/// # Example
1112+
/// ```
1113+
/// # use bashkit::Bash;
1114+
/// let bash = Bash::builder()
1115+
/// .max_memory(10 * 1024 * 1024) // 10 MB
1116+
/// .build();
1117+
/// ```
1118+
pub fn max_memory(self, bytes: usize) -> Self {
1119+
let defaults = MemoryLimits::default();
1120+
self.memory_limits(
1121+
MemoryLimits::new()
1122+
.max_total_variable_bytes(bytes)
1123+
.max_function_body_bytes(bytes.min(defaults.max_function_body_bytes)),
1124+
)
1125+
}
1126+
11031127
/// Set the trace mode for structured execution tracing.
11041128
///
11051129
/// - `TraceMode::Off` (default): No events, zero overhead
@@ -5367,4 +5391,23 @@ echo missing fi"#,
53675391
async fn test_streaming_equivalence_subshell() {
53685392
assert_streaming_equivalence("x=$(echo hello); echo $x").await;
53695393
}
5394+
5395+
#[tokio::test]
5396+
async fn test_max_memory_caps_string_growth() {
5397+
let mut bash = Bash::builder()
5398+
.max_memory(1024)
5399+
.limits(
5400+
ExecutionLimits::new()
5401+
.max_commands(10_000)
5402+
.max_loop_iterations(10_000),
5403+
)
5404+
.build();
5405+
let result = bash
5406+
.exec(r#"x=AAAAAAAAAA; i=0; while [ $i -lt 25 ]; do x="$x$x"; i=$((i+1)); done; echo ${#x}"#)
5407+
.await
5408+
.unwrap();
5409+
let len: usize = result.stdout.trim().parse().unwrap();
5410+
// 25 doublings of 10 bytes = 335 544 320 without limits; must be capped ≤ 1024
5411+
assert!(len <= 1024, "string length {len} must be ≤ 1024");
5412+
}
53705413
}

0 commit comments

Comments
 (0)