diff --git a/crates/mind-body/src/self_run/decide/tests.rs b/crates/mind-body/src/self_run/decide/tests.rs index 67186f0..bfd1bf2 100644 --- a/crates/mind-body/src/self_run/decide/tests.rs +++ b/crates/mind-body/src/self_run/decide/tests.rs @@ -479,3 +479,37 @@ fn the_ladder_allowance_is_three_escalations_the_drain_and_a_minute() { // The sweep above is only as long as this constant: pin the number the spec derives (3 * 120 + 2 + 60). assert_eq!(LADDER_S, 422); } + +#[test] +fn a_yield_closes_stdin_at_idle_only_and_a_session_with_a_line_outstanding_or_a_turn_open_waits() { + // Outstanding write (the start line has no echo yet): held. + assert_eq!(feed(working(), Event::Yield, 1).1, Action::None); + // Mid-turn: held, and it acts at the idle the result brings. + let (state, actions) = run(vec![(Event::Echo, 0), (Event::Yield, 1)]); + assert_eq!(actions[1], Action::None); + assert_eq!(feed(state, OK, 5).1, Action::CloseStdin); + // Idle: at once, and only once. + let (state, _) = idle_at(5); + let (state, action) = feed(state, Event::Yield, 6); + assert_eq!(action, Action::CloseStdin); + assert_eq!(feed(state, Event::Tick, 7).1, Action::None, "stdin is closed once"); +} + +#[test] +fn a_yield_takes_precedence_over_the_text_that_waits_and_over_a_flush_not_yet_written() { + // Text waits at idle, then the session is told to yield: nothing more is written to it. + let (state, _) = idle_at(5); + let (state, _) = feed(state, Event::Inbox { pending: true }, 5); + let after_tells = state.clone(); + assert_eq!(feed(after_tells, Event::Yield, 6).1, Action::CloseStdin); + let (state, _) = idle_at(5); + assert_eq!(feed(feed(state, Event::Yield, 5).0, Event::Inbox { pending: true }, 6).1, Action::None, "closed: nothing is written after the yield"); +} + +#[test] +fn a_flush_already_written_ends_the_session_by_itself_and_a_yield_does_not_cut_in() { + let (state, actions) = run(vec![(Event::Echo, 0), (req("a", CEILING_TOKENS), 1), (OK, 2)]); + assert_eq!(actions[2], flush(Why::Ceiling), "the ceiling's flush is written at idle"); + let (_, action) = feed(state, Event::Yield, 3); + assert_eq!(action, Action::None, "the flush's own answer closes stdin"); +} diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index da7d8ff..15b336c 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -1686,3 +1686,53 @@ fn a_brief_the_run_claims_is_in_the_task_once_and_not_again_under_also_open() { assert_eq!(start.matches("claimed follow-up").count(), 1, "{start}"); assert!(start.contains("Do the claimed thing.") && !start.contains("Also open for you"), "{start}"); } + +/// The Persona's boundary: the bubblewrap wrapper `ClaudeSpawner` runs when it has a sandbox. +#[cfg(unix)] +mod sandbox { + use std::os::unix::fs::PermissionsExt; + + use super::*; + + #[test] + fn the_sandbox_arguments_are_pinned() { + let args = sandbox_args(Path::new("/var/lib/mind-x/sandbox"), Path::new("/run/mind-x")); + let expected: Vec = [ + "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--tmpfs", "/run", "--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve", "--bind", "/run/mind-x", "/run/mind-x", "--tmpfs", + "/etc/gamecult", "--tmpfs", "/var/lib", "--bind", "/var/lib/mind-x/sandbox", "/var/lib/mind-x/sandbox", "--unshare-pid", "--die-with-parent", "--new-session", "--chdir", "/var/lib/mind-x/sandbox", "--", + ] + .map(String::from) + .to_vec(); + assert_eq!(args, expected); + // The credentials are hidden before the sandbox and the scratch are bound back, and the network is not unshared. + let at = |needle: &str| args.iter().position(|arg| arg == needle).unwrap(); + assert!(at("/run") < at("/run/mind-x") && at("/var/lib") < at("/var/lib/mind-x/sandbox")); + assert!(!args.iter().any(|arg| arg == "--unshare-net" || arg == "--unshare-all")); + } + + #[test] + fn a_sandboxed_spawner_runs_bwrap_with_its_arguments_then_claude_and_the_child_sees_no_credential() { + let dir = tempfile::tempdir().unwrap(); + let (bin, scratch, sandbox) = (dir.path().join("bin"), dir.path().join("scratch"), dir.path().join("sandbox")); + for made in [&bin, &scratch, &sandbox] { + std::fs::create_dir(made).unwrap(); + } + let (argv, environment) = (dir.path().join("argv"), dir.path().join("environment")); + let fake = bin.join("bwrap"); + std::fs::write(&fake, format!("#!/bin/sh\nprintf '%s\n' \"$@\" > {}\nenv > {}\n", argv.display(), environment.display())).unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut unit = unit_environment(); + unit.retain(|(name, _)| name != "PATH"); + unit.push(("PATH".into(), format!("{}:/usr/bin:/bin", bin.display()).into())); + unit.push((SCRATCH_VARIABLE.into(), scratch.clone().into_os_string())); + let spawner = ClaudeSpawner::in_sandbox(unit, "MIND_PERSONA_CONFIG_SEED", Some(sandbox.clone())).unwrap(); + let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary")).unwrap(); + while !matches!(child.next(StdDuration::from_secs(20)), Read::Exited(_) | Read::Timeout) {} + let recorded: Vec = std::fs::read_to_string(&argv).unwrap().lines().map(String::from).collect(); + let mut expected = sandbox_args(&sandbox, &scratch); + expected.extend(["claude", "--flag", "value"].map(String::from)); + assert_eq!(recorded, expected, "bwrap, its arguments, then claude and its own"); + let seen = std::fs::read_to_string(&environment).unwrap(); + assert!(!seen.contains("canary") && !seen.contains("UNLISTED"), "the child's environment is the allow-list: {seen}"); + } +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 02f8d4d..d888072 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -41,14 +41,7 @@ mind_body::chat_rudp: struct RudpChat pub-fields[] mind_body::cli: fn instance_and_endpoint (args : & [String] , env_endpoint : Option < String > , usage : & str) -> Result < (Slug , SocketAddr) > mind_body::cli: fn operator_name (sudo_user : Option < String > , user : Option < String >) -> String mind_body::cli: fn run (args : & [String] , root : & Path , set_by : & str , now : DateTime < Utc > , out : & mut impl Write , stop_units : & mut dyn FnMut (& Slug) -> Result < () > ,) -> Result < () > -mind_body::connector: const MAX_OUTPUT_TOKENS : u32 -mind_body::connector: fn endpoint (text : & str) -> Result < SocketAddr > -mind_body::connector: fn key (key_file_text : & str) -> String mind_body::connector: fn render_plan (windows : & [PlanQuota] , now_ms : u64) -> String -mind_body::connector: impl Connector for KitConnector -mind_body::connector: impl KitConnector -mind_body::connector: impl KitConnector: fn new (endpoint : SocketAddr , key_text : & str , model : impl Into < String > , caller : impl Into < String >) -> Result < Self > -mind_body::connector: struct KitConnector pub-fields[] mind_body::control: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum HeldReason { BrakeAbsent , BrakeHeld , Undecodable , } mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum Effective { Held { reason : HeldReason } , Released (Grant) , } mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum SwarmEffective { Held (HeldReason) , Released , } @@ -154,46 +147,52 @@ mind_body::lease_client: struct SocketLender pub-fields[] mind_body::lease_client: struct TokenFd pub-fields[] mind_body::lease_client::socket: impl Lease for Granted mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize)] # [serde (rename_all = "lowercase")] pub enum SettleOutcome { Done , Declined , Wrong , } -mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Connector , Undecodable , RoomNotAllowed , Send , SelfRun , Memory , Ack , } +mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , RoomNotAllowed , Send , SelfRun , Memory , Ack , } mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , } mind_body::persona: const AGENT : & str mind_body::persona: const DOCTRINE_MAX_BYTES : usize +mind_body::persona: const ENV_CONFIG_SEED : & str mind_body::persona: const ENV_DOCTRINE : & str -mind_body::persona: const ENV_OPERATOR : & str mind_body::persona: const ENV_MINDS : & str +mind_body::persona: const ENV_MODEL : & str +mind_body::persona: const ENV_OPERATOR : & str mind_body::persona: const ENV_ROOMS : & str +mind_body::persona: const ENV_RUN_CAP : & str mind_body::persona: const ENV_SELF_SENDER : & str -mind_body::persona: const MAX_TURNS_PER_START : u32 -mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , people : & [String] , open : & [String]) -> String -mind_body::persona: fn start (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome > +mind_body::persona: const PERSONA_POLL_S : i64 +mind_body::persona: const SANDBOX_DIR : & str +mind_body::persona: fn carry (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome > +mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String]) -> String mind_body::persona: impl Config mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) > mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) > +mind_body::persona: impl Conversation mind_body::persona: impl FileTrace mind_body::persona: impl FileTrace: fn new (path : impl Into < PathBuf >) -> Self mind_body::persona: impl HuginnPersona mind_body::persona: impl HuginnPersona: fn new (client : HuginnClient) -> Self mind_body::persona: impl PersonaState for HuginnPersona mind_body::persona: impl Sender +mind_body::persona: impl Shown mind_body::persona: impl Trace for FileTrace mind_body::persona: impl Trace for Noted < '_ > mind_body::persona: impl fmt :: Display for Failure mind_body::persona: impl fmt :: Display for Outcome -mind_body::persona: pub trait Connector { fn turn (& self , request : & TurnRequest) -> Result < String > ; fn usage_readout (& self) -> Result < UsageReadout > ; } +mind_body::persona: impl< C : ControlSource , S : ControlSource > PersonaInbox < '_ , C , S > +mind_body::persona: impl< C : ControlSource , S : ControlSource > SessionInbox for PersonaInbox < '_ , C , S > mind_body::persona: pub trait PersonaState { fn instructions (& self) -> Result < String > ; } mind_body::persona: pub trait Trace { fn record (& self , trace : & TurnTrace) -> Result < () > ; } -mind_body::persona: pub use model_connector :: { PlanQuota , UsageReadout } ; -mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, max_turns: u32, doctrine: Vec < PathBuf >] +mind_body::persona: pub use model_connector :: PlanQuota ; +mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, model: String, doctrine: Vec < PathBuf >] mind_body::persona: struct FileTrace pub-fields[] mind_body::persona: struct HuginnPersona pub-fields[] -mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, connector: & 'a dyn Connector, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, tempo: Option < & 'a dyn TempoView >] +mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, tempo: Option < & 'a dyn TempoView >, lender: & 'a dyn Lender, spawner: & 'a dyn Spawner, run_cap_s: u64, sandbox: & 'a Path, scratch: & 'a Path] mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[say: Vec < Say >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >] mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16] mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String] mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String] mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >] mind_body::persona: struct TraceItem # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[provenance: String, room: String, text: String] -mind_body::persona: struct TurnRequest # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[caller: String, request_id: String, instructions: String, input: String, output_schema_json: String] mind_body::persona: struct TurnTrace # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[run: String, turn: u32, at: DateTime < Utc >, inputs: Vec < TraceItem >, outputs: Vec < TraceItem >, outcome: String] mind_body::queue: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Breaker { Clear , Trip , } mind_body::queue: const CONSECUTIVE : usize @@ -240,17 +239,20 @@ mind_body::self_run: fn start_message (instance : & Slug , run : & PipelineRef , mind_body::self_run: fn writes_hour_cache (line : & str) -> Option < bool > mind_body::self_run: impl Child for ClaudeProcess mind_body::self_run: impl ClaudeSpawner +mind_body::self_run: impl ClaudeSpawner: fn in_sandbox (unit_environment : impl IntoIterator < Item = (OsString , OsString) > , seed_variable : & str , sandbox : Option < PathBuf >) -> Result < Self > mind_body::self_run: impl ClaudeSpawner: fn new (unit_environment : impl IntoIterator < Item = (OsString , OsString) >) -> Result < Self > mind_body::self_run: impl Drop for FreshConfig mind_body::self_run: impl FreshConfig mind_body::self_run: impl Heard mind_body::self_run: impl SelfRunOutcome mind_body::self_run: impl SelfRunOutcome: fn exit_status (& self) -> i32 +mind_body::self_run: impl SessionInbox for SelfInbox < '_ > mind_body::self_run: impl Spawner for ClaudeSpawner mind_body::self_run: impl Stop mind_body::self_run: impl fmt :: Display for SeedLinkRefused mind_body::self_run: impl fmt :: Display for SelfRunOutcome mind_body::self_run: impl std :: error :: Error for SeedLinkRefused +mind_body::self_run: impl< 'a > SelfInbox < 'a > mind_body::self_run: mod inbox mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; } mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; }