huginn: --session falls back to HUGINN_SESSION for admit, import and persona put
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc0709039f
commit
4674d5f283
3 changed files with 112 additions and 10 deletions
|
|
@ -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<String>) -> Result<String, Trouble> {
|
||||
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://<ip>:<port>`), 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`,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// The token of the hold this exact batch got: admit it.
|
||||
#[arg(long)]
|
||||
seen: Option<String>,
|
||||
|
|
@ -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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// The stored updatedAt this put replaces; omit for the first put.
|
||||
#[arg(long)]
|
||||
expected_updated_at: Option<String>,
|
||||
|
|
@ -290,6 +290,7 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
|
|||
}
|
||||
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<String>,
|
||||
seen: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
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<String>,
|
||||
expected_updated_at: Option<String>,
|
||||
file: &str,
|
||||
) -> Result<u8, Trouble> {
|
||||
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}")))?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue