From 4674d5f283695ffce168c402eea689600d3221dd Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 09:03:50 +0000 Subject: [PATCH 1/2] huginn: --session falls back to HUGINN_SESSION for admit, import and persona put Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/config.rs | 20 +++++ crates/eureka-state/src/bin/huginn/main.rs | 23 +++--- crates/eureka-state/tests/cli.rs | 79 ++++++++++++++++++++ 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/config.rs b/crates/eureka-state/src/bin/huginn/config.rs index 45d28b3..8018f3d 100644 --- a/crates/eureka-state/src/bin/huginn/config.rs +++ b/crates/eureka-state/src/bin/huginn/config.rs @@ -15,6 +15,10 @@ use crate::trouble::Trouble; pub const TOOL: &str = "huginn"; /// How long one call may take, connection to last byte. const TIMEOUT: Duration = Duration::from_secs(15); +/// The environment variable a Self run sets to its run label: the session an admitting verb attributes to when `--session` is not given. +pub const SESSION_VARIABLE: &str = "HUGINN_SESSION"; +/// `Short`'s bound in bytes (the leaf keeps it private); the daemon enforces it again. +const SESSION_MAX: usize = 200; /// What `ask_until` leaves of its deadline for the caller to print and exit. const RESERVE: Duration = Duration::from_millis(100); @@ -39,6 +43,22 @@ impl Settings { self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone())) } + /// The session an admission is attributed to: the flag as given; else `HUGINN_SESSION` when it + /// is set and not empty; else a refusal. Never guessed and never any other variable. + pub fn session(&self, flag: Option) -> Result { + if let Some(flag) = flag { + return Ok(flag); + } + match variable(SESSION_VARIABLE) { + Err(detail) => Err(Trouble::invalid(detail)), + Ok(Some(value)) if value.len() > SESSION_MAX => { + Err(Trouble::invalid(format!("{SESSION_VARIABLE} is longer than {SESSION_MAX} bytes"))) + } + Ok(Some(value)) if !value.is_empty() => Ok(value), + Ok(_) => Err(Trouble::invalid(format!("--session is required (or {SESSION_VARIABLE} set to the session's label)"))), + } + } + /// A client for another mind, read from: `instance` at `endpoint` (`rudp://:`), both /// given as arguments. It refuses this process's own instance before any call, so the one /// mind a verb writes is never also its source. Every request through it declares `instance`, diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index e33f83c..f94896f 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -4,9 +4,9 @@ //! strict decode of agent-written JSON into the leaf's types (`door`), the //! recipe selections (`recipes`), the text rendering (`render`), the exit code //! and the read-back after an admission. It owns no state, writes no file and -//! reads no configuration but two environment variables: `EUREKA_INSTANCE` +//! reads no configuration but these environment variables: `EUREKA_INSTANCE` //! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that -//! holds it. Each call is a new process; there is no retry inside it, the +//! holds it. The admitting verbs take `--session`, else `HUGINN_SESSION`. Each call is a new process; there is no retry inside it, the //! caller's rule retries `Unavailable` once. //! //! Answers go to stdout and the exit code says what the answer was: 0 answered, @@ -125,9 +125,9 @@ enum Command { /// The admitting agent's name. #[arg(long)] agent: String, - /// The admitting session. + /// The admitting session; else the environment's HUGINN_SESSION, which a Self run sets to its run. #[arg(long)] - session: String, + session: Option, /// The token of the hold this exact batch got: admit it. #[arg(long)] seen: Option, @@ -152,9 +152,9 @@ enum Command { /// The importing agent's name. #[arg(long)] agent: String, - /// The importing session. + /// The importing session; else the environment's HUGINN_SESSION. #[arg(long)] - session: String, + session: Option, }, /// Recall into a dispatched brief. Recall { @@ -198,9 +198,9 @@ enum PersonaCommand { /// The writing agent's name. #[arg(long)] agent: String, - /// The writing session. + /// The writing session; else the environment's HUGINN_SESSION. #[arg(long)] - session: String, + session: Option, /// The stored updatedAt this put replaces; omit for the first put. #[arg(long)] expected_updated_at: Option, @@ -290,6 +290,7 @@ fn run(settings: &Settings, command: Command) -> Result { } Command::Admit { faculty, agent, session, seen, file } => admit(settings, faculty, agent, session, seen, &file), Command::Import { campaign, from, from_endpoint, faculty, agent, session } => { + let session = settings.session(session)?; import::run(settings, import::Flags { campaign, from, from_endpoint, faculty, agent, session }) } Command::Persona { command: PersonaCommand::Get } => persona_get(settings), @@ -489,10 +490,11 @@ fn admit( settings: &Settings, faculty: Faculty, agent: String, - session: String, + session: Option, seen: Option, file: &str, ) -> Result { + let session = settings.session(session)?; let documents = door::documents(&door::file_or_stdin(file)?)?; if documents.is_empty() || documents.len() > BATCH_MAX { return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len()))); @@ -649,10 +651,11 @@ fn persona_put( settings: &Settings, faculty: Faculty, agent: String, - session: String, + session: Option, expected_updated_at: Option, file: &str, ) -> Result { + let session = settings.session(session)?; let text = door::file_or_stdin(file)?; let state: Value = serde_json::from_str(&text) .map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?; diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 655e760..97a8afc 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -49,6 +49,7 @@ fn run(env: &[(&str, &str)], args: &[&str], stdin: Option<&str>) -> Run { command .env_remove("EUREKA_INSTANCE") .env_remove("HUGINN_ENDPOINT") + .env_remove("HUGINN_SESSION") .envs(env.iter().copied()) .args(args) .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) @@ -2782,3 +2783,81 @@ fn import_wrong_endpoint_is_foreign_instance() { assert_eq!(own.code, 2, "{}{}", own.out, own.err); assert!(own.err.starts_with("InvalidInput: --from heimdall is this process's own instance"), "{}", own.err); } + +/// Configured for the test instance against a daemon at `addr`, with `HUGINN_SESSION` as given. +fn with_session(addr: SocketAddr, session: &str, args: &[&str], stdin: Option<&str>) -> Run { + let endpoint = format!("rudp://{addr}"); + run(&[("EUREKA_INSTANCE", INSTANCE), ("HUGINN_ENDPOINT", &endpoint), ("HUGINN_SESSION", session)], args, stdin) +} + +fn admit_without_session(file: &str) -> Vec<&str> { + vec!["admit", "--faculty", "Hands", "--agent", "claude", file] +} + +#[test] +fn the_session_is_the_flag_else_huginn_session_else_a_refusal() { + let dir = tempfile::tempdir().unwrap(); + let file = write(&dir, "campaign.json", &json!([campaign_json()])); + let server = live(); + + // Neither: refused before any send, nothing admitted. + let neither = at(server.addr, &admit_without_session(&file), None); + assert_eq!(neither.code, 2, "{}", neither.err); + assert!(neither.err.starts_with("InvalidInput: --session is required"), "{}", neither.err); + assert_eq!(neither.out, ""); + let count = at(server.addr, &["whoami"], None); + assert!(count.out.contains("receipts 2 "), "{}", count.out); + + // An empty variable is absent. + let empty = with_session(server.addr, "", &admit_without_session(&file), None); + assert_eq!(empty.code, 2, "{}", empty.err); + assert!(empty.err.starts_with("InvalidInput: --session is required"), "{}", empty.err); + assert!(at(server.addr, &["whoami"], None).out.contains("receipts 2 ")); + + // The variable alone: the receipt carries it. + let env_only = with_session(server.addr, "mind-20261111T072944197Z", &admit_without_session(&file), None); + assert_eq!(env_only.code, 0, "{}", env_only.err); + let viewed = at(server.addr, &["view", &format!("{CAMPAIGN}:campaign:self")], None); + assert!(viewed.out.contains(" Hands/claude session mind-20261111T072944197Z tool huginn "), "{}", viewed.out); + assert!(at(server.addr, &["whoami"], None).out.contains("receipts 3 ")); + + // The persona door takes it the same way. + let persona_file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z")); + let put = with_session(server.addr, "mind-run-p", &["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", &persona_file], None); + assert_eq!(put.code, 0, "{}", put.err); + let receipt = put.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&put.out); + let got = at(server.addr, &["persona", "get"], None); + assert!(got.out.contains(&format!("receipt {receipt}")), "{}", got.out); +} + +#[test] +fn the_flag_wins_over_huginn_session() { + let (server, seen) = replying("admit", HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id: "mind-commit-x".into(), committed_at: "2026-11-11T00:00:00Z".into(), writes: vec![] })); + let dir = tempfile::tempdir().unwrap(); + let file = write(&dir, "campaign.json", &json!([campaign_json()])); + let _ = with_session(server.addr, "from-env", &admit_args(&file), None); + let seen = seen.lock().unwrap(); + let sessions: Vec<&str> = seen + .iter() + .filter_map(|request| if let HuginnMindRequest::Admit(batch) = request { Some(batch.provenance.session.0.as_str()) } else { None }) + .collect(); + assert_eq!(sessions, ["session-7"]); +} + +#[test] +fn a_malformed_huginn_session_is_refused_naming_the_variable_without_echoing_it() { + let dir = tempfile::tempdir().unwrap(); + let file = write(&dir, "campaign.json", &json!([campaign_json()])); + let (server, count) = counting(HuginnMindResponse::View(None), "view"); + let long = "L".repeat(201); + let ran = with_session(server.addr, &long, &admit_without_session(&file), None); + assert_eq!(ran.code, 2, "{}", ran.err); + assert!(ran.err.starts_with("InvalidInput: HUGINN_SESSION is longer than 200 bytes"), "{}", ran.err); + assert!(!ran.err.contains("LLLL"), "{}", ran.err); + assert_eq!(*count.lock().unwrap(), 0, "nothing was sent"); + + // The bound itself is allowed. + let live = live(); + let edge = with_session(live.addr, &"E".repeat(200), &admit_without_session(&file), None); + assert_eq!(edge.code, 0, "{}", edge.err); +} -- 2.47.3 From 37be2cd3055a8b3792c93603cdad30e161781630 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 09:21:26 +0000 Subject: [PATCH 2/2] mind-self-run: the child's HUGINN_SESSION is its run's label Spawner::spawn takes the session to export; the Self run gives the bare run label (the spelling the run's opening receipt carries). The Persona gives none. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/persona.rs | 2 +- crates/mind-body/src/persona/tests.rs | 4 +-- crates/mind-body/src/self_run.rs | 29 ++++++++++++---- crates/mind-body/src/self_run/tests.rs | 34 +++++++++++++++---- .../src/self_run/tests/process/lease_child.rs | 23 +++++++++++-- crates/mind-body/src/self_run/tests/resume.rs | 6 ++-- crates/mind-body/src/testkit.rs | 7 ++-- crates/mind-body/tests/public_surface.txt | 2 +- 8 files changed, 83 insertions(+), 24 deletions(-) diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 5de05f5..d65e922 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -1006,7 +1006,7 @@ pub(crate) fn start_session( Ok(Lent::Granted(lease)) => lease, Ok(Lent::Refused(_)) | Err(_) => return Ok(Err(Failure::Lease)), }; - let Ok(child) = organs.spawner.spawn(&persona_args(session_id, &config.model, &schema, &prompt), lease.token()) else { return Ok(Err(Failure::Session)) }; + let Ok(child) = organs.spawner.spawn(&persona_args(session_id, &config.model, &schema, &prompt), lease.token(), None) else { return Ok(Err(Failure::Session)) }; Ok(Ok(Started { child, lease, started_ms })) } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 35a0446..548f066 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -247,7 +247,7 @@ struct FakeSession { } impl Spawner for FakeSession { - fn spawn(&self, args: &[String], _token: &TokenFd) -> Result> { + fn spawn(&self, args: &[String], _token: &TokenFd, _session: Option<&str>) -> Result> { if self.refuse.get() { return Err(anyhow!("refused")); } @@ -2711,7 +2711,7 @@ impl Child for Timed { struct ScriptedSpawner(RefCell>); impl crate::self_run::Spawner for ScriptedSpawner { - fn spawn(&self, _args: &[String], _token: &TokenFd) -> Result> { + fn spawn(&self, _args: &[String], _token: &TokenFd, _session: Option<&str>) -> Result> { self.0.borrow_mut().pop_front().map(|child| Box::new(child) as Box).ok_or_else(|| anyhow!("no more processes")) } } diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index a919156..9e6a4de 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -150,6 +150,8 @@ const ENV_SCRUB_VARIABLE: &str = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"; /// Optional, set by the unit: `off` turns that scrub off for a run whose user holds root, where it /// protects nothing and its sandbox's no_new_privs would only stop `sudo`. Anything else keeps it on. const SCRUB_VARIABLE: &str = "MIND_SELF_SCRUB"; +/// The variable the child's `huginn` reads its `--session` from: the run's label, the id the start message names. +const SESSION_VARIABLE: &str = "HUGINN_SESSION"; /// The name the start message tells her to admit as. pub const AGENT: &str = "mind-self-run"; /// The namespace of the session id: the run id is the name in it. @@ -227,6 +229,12 @@ fn flush_text(why: Why) -> String { format!("{cause} {FLUSH_MESSAGE} Close each brief that is done by the closing rule; a brief whose change is not on its default branch stays open, or is superseded by your own follow-up when it waits on someone else, and the next session continues it.") } +/// The label of a run, the last segment of its id (`:run: