Skip to content

Commit 1bb034b

Browse files
committed
Hold a run slot until the session reads its message, and test the sandbox on Linux
1 parent 42954f7 commit 1bb034b

6 files changed

Lines changed: 47 additions & 13 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ jobs:
2121
components: rustfmt, clippy
2222

2323
- name: Install test dependencies
24-
run: sudo apt-get update && sudo apt-get install -y ripgrep
24+
run: sudo apt-get update && sudo apt-get install -y ripgrep bubblewrap
25+
26+
# The sandbox tests run commands under bwrap, which needs unprivileged
27+
# user namespaces; Ubuntu 24.04 restricts them through AppArmor.
28+
- name: Allow unprivileged user namespaces
29+
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
2530

2631
- name: Check formatting
2732
run: cargo fmt --check

‎crates/agent-core/src/sandbox.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,9 @@ mod tests {
595595
assert!(args.contains("--unshare-net"));
596596
}
597597

598-
#[cfg(target_os = "macos")]
598+
#[cfg(any(target_os = "macos", target_os = "linux"))]
599599
#[test]
600-
fn seatbelt_enforces_the_policy() {
600+
fn the_os_sandbox_enforces_the_policy() {
601601
use std::process::Command;
602602
let dir = work("seatbelt");
603603
let outside = real(&env::temp_dir())

‎crates/agent-core/tests/hosted_engines.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ fn host_tools_run_in_the_host_and_host_prompt_reaches_the_model() {
239239
assert!(assistant_text(&events).contains("<host-marker>brief goes here</host-marker>"));
240240
}
241241

242-
#[cfg(target_os = "macos")]
242+
#[cfg(any(target_os = "macos", target_os = "linux"))]
243243
#[test]
244244
fn a_sandboxed_engine_runs_commands_inside_and_asks_to_leave() {
245245
use jucode_agent_core::sandbox::{CommandRule, RuleAction, SandboxMode, SandboxPolicy};

‎crates/daemon/src/hub.rs‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ pub struct Hub {
3434
sessions: Mutex<HashMap<String, Hosted>>,
3535
/// Sessions whose engine is running a turn or has queued messages.
3636
busy: Mutex<HashSet<String>>,
37+
/// Delivered messages a session thread has not processed yet. They hold
38+
/// a running slot: the engine still reports "ready" until it has read
39+
/// the message, and that must not free the slot early.
40+
claims: Mutex<HashMap<String, usize>>,
3741
/// When each session last received a message (ms), for routing.
3842
last_active: Mutex<HashMap<String, u64>>,
3943
/// Serializes message delivery (scheduler ticks, sends, tool calls).
@@ -70,6 +74,7 @@ impl Hub {
7074
version,
7175
sessions: Mutex::new(HashMap::new()),
7276
busy: Mutex::new(HashSet::new()),
77+
claims: Mutex::new(HashMap::new()),
7378
last_active: Mutex::new(HashMap::new()),
7479
delivering: Mutex::new(()),
7580
next_id: AtomicU64::new(0),
@@ -277,6 +282,7 @@ impl Hub {
277282
let _ = self.store.record_session_closed(id);
278283
}
279284
lock(&self.busy).remove(id);
285+
lock(&self.claims).remove(id);
280286
self.broadcast(&json!({ "type": "session_closed", "session": id }));
281287
}
282288

@@ -315,8 +321,13 @@ impl Hub {
315321
}
316322
}
317323

318-
/// Called by a session thread when its engine starts or finishes work.
324+
/// Called by a session thread with its engine's state every tick. A
325+
/// session with an unprocessed delivery stays busy.
319326
pub fn set_busy(&self, session: &str, busy: bool) {
327+
let busy = busy
328+
|| lock(&self.claims)
329+
.get(session)
330+
.is_some_and(|count| *count > 0);
320331
let changed = if busy {
321332
lock(&self.busy).insert(session.to_string())
322333
} else {
@@ -327,6 +338,17 @@ impl Hub {
327338
}
328339
}
329340

341+
/// The session thread has handed a delivered message to its engine.
342+
pub fn release_claim(&self, session: &str) {
343+
let mut claims = lock(&self.claims);
344+
if let Some(count) = claims.get_mut(session) {
345+
*count = count.saturating_sub(1);
346+
if *count == 0 {
347+
claims.remove(session);
348+
}
349+
}
350+
}
351+
330352
pub fn agents_json(&self) -> Value {
331353
let records = self.store.sessions();
332354
let busy = lock(&self.busy).clone();
@@ -610,13 +632,17 @@ impl Hub {
610632
}
611633
None => self.create_session(None, Some(&message.to))?,
612634
};
613-
self.forward(
614-
&session,
615-
json!({ "op": "user_message", "content": delivery_text(message) }),
616-
)?;
617-
// A delivered message starts (or queues) a run; count it before the
618-
// engine reports its status so the next message sees the slot taken.
635+
// A delivered message starts (or queues) a run: claim the slot before
636+
// forwarding, so the next message sees it taken.
637+
*lock(&self.claims).entry(session.clone()).or_default() += 1;
619638
lock(&self.busy).insert(session.clone());
639+
if let Err(error) = self.forward(
640+
&session,
641+
json!({ "op": "user_message", "content": delivery_text(message), "claimed": true }),
642+
) {
643+
self.release_claim(&session);
644+
return Err(error);
645+
}
620646
lock(&self.last_active).insert(session.clone(), now());
621647
self.store
622648
.record_delivered(&message.id, &session)

‎crates/daemon/src/session.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ fn apply(hub: &Hub, core: &mut AgentCore, id: &str, op: &Value) -> bool {
157157
}
158158
}
159159
let (quit, events) = protocol::apply_op(core, op);
160+
if op["claimed"] == true {
161+
hub.release_claim(id);
162+
}
160163
for event in events {
161164
publish(hub, id, event);
162165
}

‎crates/daemon/tests/daemon.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,7 @@ fn a_device_pairs_with_a_code_from_the_desktop_until_revoked() {
795795
assert!(tungstenite::connect(format!("ws://{}/?token={token}", daemon.address)).is_err());
796796
}
797797

798-
#[cfg(target_os = "macos")]
798+
#[cfg(any(target_os = "macos", target_os = "linux"))]
799799
#[test]
800800
fn an_agent_writes_its_rw_directories_and_not_its_ro_ones() {
801801
let _guard = setup();
@@ -834,7 +834,7 @@ fn an_agent_writes_its_rw_directories_and_not_its_ro_ones() {
834834
assert_eq!(fs::read_to_string(dir.join("seen.txt")).unwrap(), "boot ok");
835835
}
836836

837-
#[cfg(target_os = "macos")]
837+
#[cfg(any(target_os = "macos", target_os = "linux"))]
838838
#[test]
839839
fn an_unattended_escalation_becomes_a_pending_action() {
840840
let _guard = setup();

0 commit comments

Comments
 (0)