From a0ff765f1b9c09abedd9fe96f07388ad906c6bbf Mon Sep 17 00:00:00 2001 From: Eureka Date: Thu, 12 Nov 2026 00:59:51 +0000 Subject: [PATCH] Persona save_attachment: reply field behind BIFROST_CHAT_FETCH, the fetch step in act, the closed outcome lines Config.fetch (exactly "1", read in from_args_and_env through the lookup), SaveAttachment {room, event} on PersonaReply with the tempo_vote_beat_s present() pattern, a conditional unrequired schema property, SAVE_FAULT refusal when the flag is off or the room/event is out of bounds, Step::Answered carries zero to two notice lines (open, then save), and a gated fetch step: NoStore decided before any request, the saver called with the receipt's room id, one fixed line per outcome with the relative path only on success. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/attachment/tests.rs | 33 +-- crates/mind-body/src/bin/mind-persona.rs | 2 +- crates/mind-body/src/chat_client.rs | 2 +- crates/mind-body/src/persona.rs | 184 ++++++++++++- crates/mind-body/src/persona/tests.rs | 320 +++++++++++++++++++++- crates/mind-body/tests/public_surface.txt | 8 +- 6 files changed, 492 insertions(+), 57 deletions(-) diff --git a/crates/mind-body/src/attachment/tests.rs b/crates/mind-body/src/attachment/tests.rs index 6f72e9e..c32dac6 100644 --- a/crates/mind-body/src/attachment/tests.rs +++ b/crates/mind-body/src/attachment/tests.rs @@ -276,12 +276,12 @@ fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { CRASH_AT.set(Some(8192)); assert_eq!(world.save(&image), Err(SaveFault::WriteFailed)); CRASH_AT.set(None); - assert_eq!(world.files(), [part.clone()], "only the temporary is left, and the final name is absent"); + assert_eq!(world.files(), [part.as_str()], "only the temporary is left, and the final name is absent"); assert!(std::fs::metadata(world.attachments().join(&part)).unwrap().len() < big.len() as u64); // The retry clears the leavings and saves in full. assert!(world.save(&image).is_ok()); - assert_eq!(world.files(), [final_name.clone()]); + assert_eq!(world.files(), [final_name.as_str()]); assert_eq!(std::fs::read(world.attachments().join(&final_name)).unwrap(), big); // A FIFO or a link at the temporary name does not stop the next save either. @@ -294,31 +294,14 @@ fn a_crashed_write_leaves_no_partial_final_name_and_a_retry_saves_in_full() { assert_eq!(world.files(), [final_name]); } -/// The child half of the mode test: a no-op unless the parent started it under a chosen umask. +/// The create call carries 0600, so the runner's umask can only narrow it: a looser mode in that call shows as 0644 or 0666 under the +/// common umask 022, and a tighter one as less than 0600. (The library reads no environment and forbids unsafe, so the test does not +/// set a umask; the source has no chmod, which the negative scan pins.) #[test] -fn mode_child() { - let Ok(sandbox) = std::env::var("ATTACHMENT_MODE_CHILD") else { return }; - let world = World { store: tempfile::tempdir().unwrap(), sandbox: tempfile::TempDir::new_in(sandbox).unwrap() }; +fn the_saved_file_is_mode_0600_and_the_directory_0700() { + let world = World::new(); let image = world.put(ROOM, PNG, "png"); let path = world.save(&image).unwrap(); - let mode = std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777; - assert_eq!(mode, 0o600); - // The directory the saver makes is owner-only too. + assert_eq!(std::fs::metadata(world.sandbox.path().join(path)).unwrap().permissions().mode() & 0o7777, 0o600); assert_eq!(std::fs::metadata(world.attachments()).unwrap().permissions().mode() & 0o7777, 0o700); } - -#[test] -fn the_saved_file_is_mode_0600_under_any_umask() { - let scratch = tempfile::tempdir().unwrap(); - let exe = std::env::current_exe().unwrap(); - for umask in ["0", "022", "077"] { - let status = std::process::Command::new("sh") - .args(["-c", &format!("umask {umask} && exec \"$0\" --exact attachment::tests::mode_child --nocapture")]) - .arg(&exe) - .env("ATTACHMENT_MODE_CHILD", scratch.path()) - .output() - .unwrap(); - assert!(status.status.success(), "umask {umask}: {}", String::from_utf8_lossy(&status.stdout)); - assert!(String::from_utf8_lossy(&status.stdout).contains("1 passed"), "the child ran: {}", String::from_utf8_lossy(&status.stdout)); - } -} diff --git a/crates/mind-body/src/bin/mind-persona.rs b/crates/mind-body/src/bin/mind-persona.rs index 1faca96..8f104dc 100644 --- a/crates/mind-body/src/bin/mind-persona.rs +++ b/crates/mind-body/src/bin/mind-persona.rs @@ -19,7 +19,7 @@ //! is never in this process's environment, only the descriptor a lease grants); `RUNTIME_DIRECTORY` (the //! unit's `RuntimeDirectory=`) holds each process's configuration and instructions; `BIFROST_CHAT_ENDPOINT` //! (`rudp://:`, the mouth) and `BIFROST_CHAT_KEY_FILE`; `BIFROST_CHAT_PENDING_V4` set to exactly `1` asks the mouth for the -//! reply link (`chat_pending.v4`), anything else or unset does not. Without +//! reply link (`chat_pending.v4`), anything else or unset does not. `BIFROST_CHAT_FETCH` set to exactly `1` lets her ask the mouth to keep one event's image (`save_attachment`); the operator sets it only once the mouth runs `CHAT_FETCH=1`. Without //! `--endpoint` the daemon is `HUGINN_ENDPOINT`; the trace is `persona-trace.cc` //! under `STATE_DIRECTORY`, and her sandbox is `STATE_DIRECTORY/sandbox`. The key file is read here and its //! text goes to the transport, which holds it; nothing is printed. diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 02c0dbd..dc677dc 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -139,7 +139,7 @@ fn matrix_user_id(text: &str) -> bool { /// Whether `text` has the shape of an event id: `$` and at least one ASCII graphic byte, at most 255 bytes. Deliberately not a base64 /// check: room versions 3 to 12 and the legacy `$opaque:server` ids all pass, because the mouth forwards whatever the homeserver named. -fn event_id_shape(text: &str) -> bool { +pub(crate) fn event_id_shape(text: &str) -> bool { text.len() <= 255 && text.strip_prefix('$').is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_graphic())) } diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 5198bf7..51e7f13 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -85,8 +85,9 @@ use serde_json::{Value, json}; use uuid::Uuid; use crate::address::{Addressee, Name, addresses, is_for, localpart, project_names}; +use crate::attachment::{self, SaveFault}; use crate::chat_image::{self, Image, MAX_PER_LINE}; -use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, ReplyTo, SendBasis}; +use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatFetchRequest, event_id_shape, ChatOpenDirectRequest, ChatRead, DirectOutcome, FetchOutcome, ReceiptMode, ChatSendRequest, ContextLine, Refusal, ReplyTo, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; use crate::heartbeat::{HeartbeatView, PollTrace, stewardships_of}; @@ -141,6 +142,8 @@ pub const ENV_CONFIG_SEED: &str = "MIND_PERSONA_CONFIG_SEED"; pub const ENV_DOCTRINE: &str = "MIND_PERSONA_DOCTRINE"; /// The absolute path of her image store (`Config::image_store`). pub const ENV_IMAGE_STORE: &str = "MIND_PERSONA_IMAGE_STORE"; +/// The mouth's attachment fetch is served: exactly `1` turns it on (`Config::fetch`), as `BIFROST_CHAT_PENDING_V4` does for the reply link. +const ENV_FETCH: &str = "BIFROST_CHAT_FETCH"; /// The most doctrine text, in bytes, one process's instructions may carry. pub const DOCTRINE_MAX_BYTES: usize = 96 * 1024; /// How many of her latest runs a turn's input lists. @@ -159,6 +162,7 @@ const START_FAULT: &str = "start_self_run: its brief, repo or campaign is out of const VOTE_FAULT: &str = "tempo_vote_beat_s or urgency_permille: outside its range"; const ABOUT_FAULT: &str = "remember.about: every entry must be an id copied exactly from a line of this session"; const SETTLE_ITEM_FAULT: &str = "settle.item: not an open item this conversation showed"; +const SAVE_FAULT: &str = "save_attachment: not offered here, or its room is not one you may say in, or its event is not an event id"; const OPEN_DIRECT_FAULT: &str = "open_direct: only a public conversation may open one, with a person this session showed (not a Mind, not you), and a text of 1 to 4000 bytes"; /// The mouth's bound on an invitation's text, in bytes. const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; @@ -215,6 +219,9 @@ pub struct Config { pub doctrine: Vec, /// Her image store: where the mouth keeps the images it fetched for her, one directory per room. None shows no image. pub image_store: Option, + /// Whether she may ask the mouth to fetch one event's image: the operator's declaration that the mouth runs `CHAT_FETCH=1` + /// (`BIFROST_CHAT_FETCH`, exactly `1`). Off, the reply schema has no `save_attachment` and no request leaves the organ. + pub fetch: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -267,7 +274,7 @@ impl Config { if items.iter().any(String::is_empty) { Err(anyhow!("{usage}")) } else { Ok(items) } }; let rooms = list(rooms)?; - let config = Self { rooms, operators: list(operators)?, minds: list(minds)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, model: String::new(), doctrine: Vec::new(), image_store: None }; + let config = Self { rooms, operators: list(operators)?, minds: list(minds)?, own_sender: own.ok_or_else(|| anyhow!("{usage}"))?, model: String::new(), doctrine: Vec::new(), image_store: None, fetch: false }; Ok((config, rest)) } @@ -294,6 +301,7 @@ impl Config { bail!("{ENV_DOCTRINE} names a path that is not absolute"); } } + config.fetch = lookup(ENV_FETCH).as_deref() == Some("1"); if let Some(path) = lookup(ENV_IMAGE_STORE).filter(|value| !value.is_empty()) { if !Path::new(&path).is_absolute() { bail!("{ENV_IMAGE_STORE} is not an absolute path"); @@ -336,6 +344,10 @@ pub struct PersonaReply { /// The urgency she declares for herself until her next turn, in thousandths; null declares none. #[serde(deserialize_with = "Option::deserialize")] pub urgency_permille: Option, + /// One event's image she asks the mouth for, to keep in her sandbox. Present only where the fetch flag is on: omitted or null is no ask; + /// a reply that names the key with the flag off decodes and is refused as a bound, with nothing sent. + #[serde(default, deserialize_with = "present")] + pub save_attachment: Option>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -354,6 +366,15 @@ pub struct OpenDirect { pub text: String, } +/// Her ask that the mouth fetch the image of `event` in `room` (an alias she may say in) for her sandbox. Room and event only: nothing +/// else about the file leaves the organ. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SaveAttachment { + pub room: String, + pub event: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StartSelfRun { @@ -473,7 +494,9 @@ pub(crate) fn remember_item_schema() -> Value { }) } -pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> String { +const SAVE_ATTACHMENT_DESCRIPTION: &str = "Keep one image from a room's event in your sandbox, or null (or omit the key). `room` is the room name as the attachment line shows it and `event` is the event id shown there. The mouth checks you are a member, fetches the file, verifies it and tells the room you received it; the file lands in your sandbox as attachments/., and the outcome is told to you next turn. One per reply."; + +pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool, fetch: bool) -> String { let remember = json!({ "description": REMEMBER_DESCRIPTION, "type": "array", @@ -550,7 +573,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S } else { json!({ "type": "null" }) }; - json!({ + let mut schema = json!({ "type": "object", "additionalProperties": false, "required": ["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"], @@ -576,8 +599,23 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S "tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you abstain again and are not counted.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] }, "urgency_permille": { "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": 0, "maximum": URGENCY_MAX_PERMILLE }] } } - }) - .to_string() + }); + if fetch { + // Not required, like tempo_vote_beat_s: omission and null are no ask. + schema["properties"]["save_attachment"] = json!({ + "description": SAVE_ATTACHMENT_DESCRIPTION, + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["room", "event"], + "properties": { "room": { "type": "string", "enum": rooms }, "event": { "type": "string", "minLength": 2, "maxLength": 255 } } + } + ] + }); + } + schema.to_string() } /// What the plan's usage windows say, as the lease grant carries them: the kit's own type, not a copy. @@ -745,7 +783,8 @@ pub struct Organs<'a> { pub spawner: &'a dyn Spawner, /// The unit's `RuntimeMaxSec` in seconds: the one length of a start. pub run_cap_s: u64, - /// Her sandbox: the one directory her tools write, made owner-only if absent. Never read by an organ. + /// Her sandbox: the one directory her tools write, made owner-only if absent. Never read by an organ, except the one named regular file the + /// attachment saver compares after EEXIST (bounded, nonblocking, no listing). pub sandbox: &'a Path, /// The unit's runtime directory: each process's instructions file is written here. pub scratch: &'a Path, @@ -833,8 +872,8 @@ impl fmt::Display for Outcome { /// How one result ended. enum Step { - /// Answered; the Self run its reply started or found live and the follow-up it admitted for it, if any, the intent it stated, the ids of the open items (Asked, Committed) it wrote, and the notice line of the DM it asked the mouth to open, if any. - Answered(Option<(PipelineRef, PipelineRef)>, String, Vec, Option), + /// Answered; the Self run its reply started or found live and the follow-up it admitted for it, if any, the intent it stated, the ids of the open items (Asked, Committed) it wrote, and the notice lines of what she asked the mouth: zero to two, the DM open first and the attachment save second. + Answered(Option<(PipelineRef, PipelineRef)>, String, Vec, Vec), /// The reply could not be decoded: it is told back to her, and sends and acks nothing. Refused(Failure), /// The mouth refused a send because the room moved past what her input held: the room, the rooms sent before it in the @@ -1012,7 +1051,7 @@ fn one_process( let session_id = Uuid::new_v5(&SESSION_NAMESPACE, format!("{}:p{process}", run.id.0).as_bytes()); let rooms = reply_rooms(config, &session.conversation); let may_open = session.conversation == Conversation::Public; - let schema_of = |campaigns: &[(String, String)]| reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::>(), may_open); + let schema_of = |campaigns: &[(String, String)]| reply_schema(&rooms, &campaigns.iter().map(|(slug, _)| slug.clone()).collect::>(), may_open, config.fetch); let Started { child, lease, started_ms } = match start_session(organs, config, session_id, remaining_s, schema_of)? { Ok(started) => started, Err(why) => { @@ -1843,8 +1882,8 @@ impl SessionInbox for PersonaInbox<'_, C, S> } }; match step { - Step::Answered(started, intent, opened, notice) => { - self.notices.extend(notice); + Step::Answered(started, intent, opened, notices) => { + self.notices.extend(notices); // The result answers the replies its lines carried: they are heard now, and only now. let by = started.as_ref().map(|(_, follow_up)| follow_up.clone()); self.answered_replies.extend(heard.into_iter().map(|reply| (reply, by.clone()))); @@ -2079,7 +2118,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch } outputs.push(TraceItem { provenance: "her own".into(), room: say.room.clone(), text }); } - let mut notice = None; + let mut notices = Vec::new(); if let Some(ask) = &reply.open_direct { // The gate binds the open as it binds a say. if let Effective::Held { reason } = read(control, swarm, &instance) { @@ -2095,7 +2134,19 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch let outcome = answer.ok().map(|receipt| receipt.outcome); outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, unserved.or(outcome.map(DirectOutcome::name)).unwrap_or("unanswered")) }); let told = if unserved.is_some() { format!("Your invitation to {} was not sent: the mouth does not serve invitations yet.", ask.with) } else { open_told(&ask.with, outcome) }; - notice = Some(organ_notice(unix_ms(organs.clock.now()), &told)); + notices.push(organ_notice(unix_ms(organs.clock.now()), &told)); + } + if let Some(Some(ask)) = &reply.save_attachment { + // The gate binds the fetch as it binds a say and an open; each step has its own request id. + if let Effective::Held { reason } = read(control, swarm, &instance) { + organs.trace.record(&record(outputs, format!("failed: {}", Failure::Held(reason))))?; + return Ok(Step::Failed(Failure::Held(reason), reply.intent)); + } + let request_id = if finality.revised { format!("{batch_id}-r{turn}-a") } else { format!("{batch_id}-a") }; + // A refusal, a fault and a silent mouth are told, never a failed step. Nothing is retried in the turn. + let saved = save_attachment(organs, config, &instance.0, request_id, ask); + outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("save_attachment {} {}: {}", ask.room, ask.event, saved.word()) }); + notices.push(organ_notice(unix_ms(organs.clock.now()), &saved.told())); } // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); @@ -2131,7 +2182,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch } organs.trace.record(&record(outputs, "answered".into()))?; let opened = reply.remember.iter().zip(&memories).filter(|(remember, _)| matches!(remember.sort, MemorySort::Asked | MemorySort::Committed)).filter_map(|(_, memory)| pipeline_key(memory).ok()).collect(); - Ok(Step::Answered(started, reply.intent, opened, notice)) + Ok(Step::Answered(started, reply.intent, opened, notices)) } /// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to any of @@ -2366,6 +2417,17 @@ fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config && ask.text.len() <= OPEN_DIRECT_TEXT_MAX_BYTES } +/// Whether the save is hers to ask. With the flag off the key must be absent, null included; on, an ask names a room she may say in and +/// an event id's shape. +fn save_in_bounds(reply: &PersonaReply, rooms: &[String], config: &Config) -> bool { + match &reply.save_attachment { + None => true, + Some(_) if !config.fetch => false, + Some(None) => true, + Some(Some(ask)) => rooms.contains(&ask.room) && event_id_shape(&ask.event), + } +} + /// The reply as the one type it must be, with every room one of `rooms`, every person one of /// `people` and every settled or taken-on item one of the `open` items shown this turn. fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result { @@ -2391,6 +2453,9 @@ fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conve if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) { return Err(Failure::RoomNotAllowed); } + if !save_in_bounds(&reply, rooms, config) { + return Err(Failure::OutOfBounds(SAVE_FAULT)); + } Ok(reply) } @@ -3238,6 +3303,95 @@ fn open_told(with: &str, outcome: Option) -> String { } } +/// What became of her save: a closed set, so nothing the mouth wrote reaches her. The three saved lines are their own arms. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Saved { + /// Saved and verified; `receipt` Posted or Existing means the room shows it received, none means no receipt was posted. + Stored { path: String, receipt: Option }, + /// Saved, the event carried no hash: not verified, no receipt, never marked read. + Unverified { path: String }, + /// One of the mouth's twelve refusal words. + Refused(FetchOutcome), + /// The mouth refused the kind (UnknownKind): nothing was sent. + Unserved, + /// The mouth did not answer: it may or may not have fetched. + Unanswered, + /// No image store: decided before any request. + NoStore, + Fault(SaveFault), +} + +impl Saved { + /// The outcome as the trace spells it. + fn word(&self) -> String { + match self { + Self::Stored { receipt: Some(_), .. } => "Stored".into(), + Self::Stored { receipt: None, .. } => "StoredNoReceipt".into(), + Self::Unverified { .. } => FetchOutcome::StoredUnverified.name().into(), + Self::Refused(outcome) => outcome.name().into(), + Self::Unserved => "Unserved".into(), + Self::Unanswered => "Unanswered".into(), + Self::NoStore => "NoStore".into(), + Self::Fault(SaveFault::Load(skip)) => format!("Load {skip:?}"), + Self::Fault(fault) => format!("{fault:?}"), + } + } + + /// The one fixed line she is told. Only the saved lines carry the relative path. + fn told(&self) -> String { + use FetchOutcome as F; + match self { + Self::Stored { path, receipt: Some(_) } => format!("Your attachment is saved at {path} in your sandbox and checked against its hash. The room shows you received it."), + Self::Stored { path, receipt: None } => format!("Your attachment is saved at {path} in your sandbox and checked against its hash, but no receipt was posted, so it is not marked read."), + Self::Unverified { path } => format!("Your attachment is saved at {path} in your sandbox but not verified: the event carried no hash. No receipt was posted and it is not marked read."), + Self::Refused(outcome) => match outcome { + F::NotAMember => "Your attachment was not saved: you are not a member of that room.", + F::BridgedRoom => "Your attachment was not saved: that room is bridged from another network, and the mouth does not fetch from it.", + F::TooLarge => "Your attachment was not saved: the image is too large for the mouth to keep.", + F::TypeRefused => "Your attachment was not saved: the mouth keeps png, jpeg, gif and webp from unencrypted rooms only, and that file is not one it can keep.", + F::NoAttachment => "Your attachment was not saved: that event has no image attached.", + F::HashMismatch => "Your attachment was not saved: the file does not match the hash its event declared.", + F::RateLimited => "Your attachment was not saved: the mouth's hourly limit on saves for you is reached. Do not ask again this turn.", + F::Brake => "Your attachment was not saved: the brake is held.", + F::PersonaMismatch => "Your attachment was not saved: the mouth took the request for another Persona.", + F::UnknownPersona => "Your attachment was not saved: the mouth does not know you.", + F::FetchFailed => "Your attachment was not saved: the mouth could not fetch the file from the homeserver.", + F::StoreFailed => "Your attachment was not saved: the mouth could not store the file.", + F::Stored | F::StoredUnverified => unreachable!("a stored outcome is a saved line, not a refusal"), + } + .into(), + Self::Unserved => "Your attachment was not saved: the mouth does not serve attachments yet.".into(), + Self::Unanswered => "The mouth did not answer your request to keep an attachment, so it may or may not have gone through. Do not repeat it unless the file is missing.".into(), + Self::NoStore => format!("Your attachment was not saved: {}.", chat_image::Skip::NoStore.reason()), + Self::Fault(SaveFault::RepoTree) => "Your attachment was not saved: your sandbox, or its attachments folder, is a git work tree.".into(), + Self::Fault(SaveFault::OutsideSandbox) => "Your attachment was not saved: attachments in your sandbox is a link or not a folder.".into(), + Self::Fault(SaveFault::Exists) => "Your attachment was not saved: a different file already holds that name in attachments, and nothing was replaced.".into(), + Self::Fault(SaveFault::WriteFailed) => "Your attachment was not saved: the file could not be written into your sandbox.".into(), + Self::Fault(SaveFault::Load(skip)) => format!("Your attachment was not saved: {}.", skip.reason()), + } + } +} + +/// Asks the mouth for `ask`'s image and keeps what it verified. The store is checked before anything is sent; the organ posts no receipt +/// (the mouth does, as her, in the same request); a saved file's room id is the receipt's, which the mouth resolved from the alias. +fn save_attachment(organs: &Organs<'_>, config: &Config, persona: &str, request_id: String, ask: &SaveAttachment) -> Saved { + let Some(store) = config.image_store.as_deref() else { return Saved::NoStore }; + let request = ChatFetchRequest { request_id, persona: persona.into(), room: ask.room.clone(), event_id: ask.event.clone() }; + let receipt = match organs.chat.fetch(&request) { + Ok(receipt) => receipt, + Err(error) if error.downcast_ref::().is_some() => return Saved::Unserved, + Err(_) => return Saved::Unanswered, + }; + let (Some(room_id), Some(image)) = (&receipt.room_id, &receipt.image) else { return Saved::Refused(receipt.outcome) }; + match attachment::save(store, organs.sandbox, room_id, image) { + Ok(path) => { + let path = path.to_string_lossy().into_owned(); + if receipt.outcome == FetchOutcome::StoredUnverified { Saved::Unverified { path } } else { Saved::Stored { path, receipt: receipt.receipt } } + } + Err(fault) => Saved::Fault(fault), + } +} + /// The most the wear section may take of a first line. const WEAR_MAX_BYTES: usize = 1536; /// A campaign of hers whose newest admission in her mind is this old or older, and which holds an unreported spec, an open Soul diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 897c131..a9dcaf7 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -84,6 +84,10 @@ struct FakeChat { open_outcome: Cell>, /// The mouth refuses the open kind outright (an older mouth): the open errors with the typed refusal. open_unserved: Cell, + /// The fetches asked of the mouth, in order. + fetches: RefCell>, + /// How the fake mouth answers a fetch. + fetch_answer: RefCell, /// Every line the fake homeserver holds, oldest first; none is a mouth with no tail. The reply carries them as the real mouth does: /// rooms she may speak in only, 20 a room and 60 in all, newest kept, oldest first (Bifrost `docs/bridge.md` "Room tail"). homeserver: RefCell>>, @@ -95,6 +99,16 @@ struct FakeChat { fail_pending: Cell, } +/// What a fake mouth does with a fetch. +#[derive(Clone)] +enum FetchAnswer { + Receipt(crate::chat_client::ChatFetchReceipt), + /// The kind is refused as unlisted. + Unserved, + /// No answer: the transport errors. + Down, +} + impl FakeChat { fn tail_reply(&self) -> Option> { let held = self.homeserver.borrow(); @@ -193,8 +207,14 @@ impl Chat for FakeChat { Ok(ChatOpenDirectReceipt { request_id: request.request_id.clone(), outcome, room_id: landed.then(|| "!dm:chat".to_string()), event_id: landed.then(|| "$open".to_string()) }) } - fn fetch(&self, _: &crate::chat_client::ChatFetchRequest) -> Result { - panic!("no test asks the fake mouth to fetch yet") + fn fetch(&self, request: &crate::chat_client::ChatFetchRequest) -> Result { + self.log.borrow_mut().push("fetch".into()); + self.fetches.borrow_mut().push(request.clone()); + match self.fetch_answer.borrow().clone() { + FetchAnswer::Receipt(receipt) => Ok(crate::chat_client::ChatFetchReceipt { request_id: request.request_id.clone(), ..receipt }), + FetchAnswer::Unserved => Err(crate::chat_client::MouthRefused(Refusal::UnknownKind).into()), + FetchAnswer::Down => Err(anyhow!("CANARY-fetch-down {}", request.event_id)), + } } fn read(&self, read: &ChatRead) -> Result<()> { @@ -429,6 +449,7 @@ fn config() -> Config { model: "claude-test-model".into(), doctrine: vec![], image_store: None, + fetch: false, } } @@ -506,6 +527,8 @@ impl Rig { opens: RefCell::default(), open_outcome: Cell::new(Some(DirectOutcome::Opened)), open_unserved: Cell::new(false), + fetches: RefCell::default(), + fetch_answer: RefCell::new(FetchAnswer::Down), homeserver: RefCell::new(None), dms: RefCell::default(), gaps: RefCell::new(Vec::new()), @@ -945,7 +968,7 @@ fn the_decoder_holds_the_bounds_the_schema_states() { assert!(!ok(brief, repo, campaign), "{repo:?} {campaign:?} brief of {}", brief.chars().count()); } // The decoder's bounds are the schema's, read from one place. - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&c.rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); let ask = &schema["properties"]["start_self_run"]["anyOf"][1]["properties"]; assert_eq!(ask["repo"]["pattern"], REPO_PATTERN); assert_eq!(ask["campaign"]["enum"], json!([CAMPAIGN])); @@ -1642,7 +1665,7 @@ fn a_vote_or_urgency_outside_its_bounds_fails_the_turn_without_a_pulse_or_an_ack #[test] fn the_reply_schema_bounds_the_vote_and_the_urgency_to_the_pulse_kind() { - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true, false)).unwrap(); assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"])); assert_eq!(schema["properties"]["tempo_vote_beat_s"]["anyOf"][1], json!({ "type": "integer", "minimum": 60, "maximum": 86_400 })); let described = schema["properties"]["tempo_vote_beat_s"]["description"].as_str().expect("the vote key is described to the model"); @@ -1867,7 +1890,7 @@ fn the_persona_flags_come_from_the_environment_when_the_command_line_lacks_them( let (config, rest) = Config::from_args_and_env(&args("--instance eureka"), env, "usage").unwrap(); assert_eq!( config, - Config { rooms: vec!["swarm".into(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], minds: vec!["@libby:chat".into(), "@aqua:chat".into()], own_sender: "@eureka:chat".into(), model: "claude-test-model".into(), doctrine: vec![], image_store: None } + Config { rooms: vec!["swarm".into(), "usage".into()], operators: vec!["@op:chat".into(), "@op2:chat".into()], minds: vec!["@libby:chat".into(), "@aqua:chat".into()], own_sender: "@eureka:chat".into(), model: "claude-test-model".into(), doctrine: vec![], image_store: None, fetch: false } ); assert_eq!(rest, ["--instance", "eureka"]); // A flag wins over its variable, one at a time. @@ -2160,7 +2183,7 @@ fn where_and_from_whom_she_learned_a_memory_comes_from_the_batch_and_no_model_fi let (_, memory) = memories(&wake).remove(0); assert_eq!((memory.origin, memory.sources.len()), (MemoryOrigin::Wake, 0)); // The schema has no such fields and the decoder refuses them. - let schema = reply_schema(&["swarm".to_string()], &[CAMPAIGN.to_string()], true); + let schema = reply_schema(&["swarm".to_string()], &[CAMPAIGN.to_string()], true, false); for field in ["origin", "heard_from", "sources"] { assert!(!schema.contains(field), "{field}"); } @@ -2191,7 +2214,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() { assert!(!ok(json!([{ "sort": "Idea", "text": "x", "about": [], "salience_permille": 1001 }]))); let without = reply(&[], json!([]), json!([]), json!(null)).replace("\"remember\":[],", ""); assert_eq!(decode_reply(&without, &rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "remember is required"); - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); let items = &schema["properties"]["remember"]["items"]["properties"]; assert_eq!(items["about"]["items"], json!({ "type": "string" }), "the people she may name change with every batch: the decoder holds them, the line lists them"); assert_eq!(items["sort"]["enum"], json!(["Asked", "Decided", "Committed", "Learned", "Did", "Idea", "Rapport"])); @@ -2271,7 +2294,7 @@ fn the_open_items_shown_are_what_the_decoder_lets_her_settle_or_take_on() { assert_eq!(decode_reply(&taking("yggdrasil:memory:b"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT))); assert_eq!(decode_reply(&taking("new:0"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)), "new: names a memory this reply writes"); // The schema leaves the ids to the decoder and the line, and still offers the one-reply form. - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true, false)).unwrap(); assert_eq!(schema["properties"]["settle"]["items"]["properties"]["item"], json!({ "type": "string" })); assert_eq!(schema["properties"]["start_self_run"]["anyOf"][1]["properties"]["for_item"]["anyOf"], json!([{ "type": "null" }, { "type": "string" }])); } @@ -3441,7 +3464,7 @@ fn a_first_line_with_messages_ends_on_the_work_question_after_them() { #[test] fn a_reply_states_its_intent_first() { - let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap(); + let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true, false)).unwrap(); assert_eq!(schema["required"][0], json!("intent")); assert!(schema["properties"]["intent"]["description"].as_str().unwrap().contains("whose decision you wait on") && INTENT_DESCRIPTION.ends_with("If you have already asked and nothing more can be done, say so and name the next thing.")); let rooms = ["operator".to_string()]; @@ -7914,14 +7937,14 @@ fn open_fault(reply: &str, people: &[String], conversation: &Conversation, confi #[test] fn open_direct_is_null_outside_a_public_session() { let (rooms, campaigns) = (vec!["swarm".to_string()], vec![CAMPAIGN.to_string()]); - let public: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true)).unwrap(); + let public: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, false)).unwrap(); let ask = &public["properties"]["open_direct"]["anyOf"]; assert_eq!(ask[0], json!({ "type": "null" })); assert_eq!(ask[1]["required"], json!(["with", "text"])); assert_eq!(ask[1]["properties"]["with"], json!({ "type": "string" }), "the people grow during the session: not an enum"); assert_eq!(ask[1]["properties"]["text"]["minLength"], json!(1)); assert!(ask[1]["description"].as_str().unwrap().contains("until they write to you first"), "{}", ask[1]["description"]); - let direct: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, false)).unwrap(); + let direct: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, false, false)).unwrap(); assert_eq!(direct["properties"]["open_direct"], json!({ "type": "null" })); assert!(direct["required"].as_array().unwrap().contains(&json!("open_direct")), "required in both"); @@ -8113,7 +8136,7 @@ fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() { #[test] fn the_daily_creation_policy_has_one_text_in_the_schema_and_the_notice() { - let schema = reply_schema(&["swarm".to_string()], &[], true); + let schema = reply_schema(&["swarm".to_string()], &[], true, false); let told = open_told("@ann:chat", Some(DirectOutcome::DailyLimit)); assert!(told.contains(OPEN_DAILY_POLICY), "{told}"); let schema: Value = serde_json::from_str(&schema).unwrap(); @@ -8889,3 +8912,276 @@ fn an_event_told_and_unanswered_that_the_mouth_renumbers_is_not_told_again() { let told: String = rig.model.requests.borrow().iter().map(|told| told.input.clone()).collect::>().join("\n"); assert_eq!(told.matches("text=\"first\"").count(), 1, "{told}"); } + +// ---- the attachment save: a reply field, behind the fetch flag, and the verified file in her sandbox ---- + +fn saving(room: &str, event: &str) -> String { + json!({ "intent": "Keep the picture", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null, "save_attachment": { "room": room, "event": event } }).to_string() +} + +fn with_key(key: serde_json::Value) -> String { + let mut reply: serde_json::Value = serde_json::from_str(&says(&[])).unwrap(); + reply["save_attachment"] = key; + reply.to_string() +} + +fn on() -> Config { + Config { fetch: true, ..config() } +} + +fn save_fault(reply: &str, config: &Config) -> Option { + decode_reply(reply, &config.rooms, &Shown::default(), &Conversation::Public, config, &addressing_of(config, None, &own_addressee(config))).err() +} + +/// A rig whose store holds `PNG_BYTES` for `!swarm:chat` and whose mouth answers a fetch with `outcome`, the room id and that image. +fn fetching(outcome: crate::chat_client::FetchOutcome, receipt: Option, replies: &[String]) -> (Rig, tempfile::TempDir, ChatImage) { + let rig = public_rig(replies); + let store = tempfile::tempdir().unwrap(); + let image = stored_image(store.path(), "!swarm:chat", PNG_BYTES); + let stored = outcome.stored(); + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Receipt(crate::chat_client::ChatFetchReceipt { request_id: String::new(), outcome, room_id: stored.then(|| "!swarm:chat".to_string()), image: stored.then(|| image.clone()), receipt }); + (rig, store, image) +} + +fn kept(rig: &Rig) -> Vec { + let mut names: Vec = std::fs::read_dir(rig.sandbox().join("attachments")).map(|dir| dir.map(|e| e.unwrap().file_name().into_string().unwrap()).collect()).unwrap_or_default(); + names.sort(); + names +} + +#[test] +fn with_the_flag_off_the_schema_has_no_save_attachment_and_with_it_on_the_property_is_optional() { + let (rooms, campaigns) = (vec!["swarm".to_string()], vec![CAMPAIGN.to_string()]); + let off: Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, false)).unwrap(); + assert!(off["properties"].get("save_attachment").is_none()); + assert!(!off["required"].as_array().unwrap().iter().any(|name| name == "save_attachment")); + assert!(!off.to_string().contains("save_attachment")); + let on: Value = serde_json::from_str(&reply_schema(&rooms, &campaigns, true, true)).unwrap(); + assert!(on["properties"].get("save_attachment").is_some()); + assert!(!on["required"].as_array().unwrap().iter().any(|name| name == "save_attachment"), "like tempo_vote_beat_s: omission is no ask"); + assert_eq!(on["properties"]["save_attachment"]["anyOf"][1]["properties"]["room"]["enum"], json!(["swarm"]), "only rooms she may say in"); +} + +#[test] +fn a_flag_off_reply_naming_save_attachment_is_out_of_bounds_and_an_omitting_one_decodes_as_before() { + let fault = Some(Failure::OutOfBounds(SAVE_FAULT)); + assert_eq!(save_fault(&saving("swarm", "$e1"), &config()), fault); + assert_eq!(save_fault(&with_key(json!(null)), &config()), fault, "null names the key too"); + assert_eq!(save_fault(&says(&[]), &config()), None, "omitted decodes exactly as before"); + // And nothing leaves the organ for it. + let rig = public_rig(&[saving("swarm", "$e1"), saving("swarm", "$e1"), saving("swarm", "$e1")]); + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Unserved; + let _ = rig.go(); + assert!(rig.chat.fetches.borrow().is_empty()); +} + +#[test] +fn with_the_flag_on_an_omitted_or_null_field_is_no_ask_and_an_object_is_bounds_checked() { + assert_eq!(save_fault(&says(&[]), &on()), None); + assert_eq!(save_fault(&with_key(json!(null)), &on()), None); + assert_eq!(save_fault(&saving("swarm", "$e1"), &on()), None); + let fault = Some(Failure::OutOfBounds(SAVE_FAULT)); + assert_eq!(save_fault(&saving("not-a-room", "$e1"), &on()), fault, "a room outside the reply rooms"); + assert_eq!(save_fault(&saving("!swarm:chat", "$e1"), &on()), fault, "a room id is not an alias she was shown"); + for event in ["", "$", "e1", "$e 1", "$\u{e9}", &format!("${}", "x".repeat(255))] { + assert_eq!(save_fault(&saving("swarm", event), &on()), fault, "{event:?}"); + } + assert_eq!(save_fault(&saving("swarm", &format!("${}", "x".repeat(254))), &on()), None); + assert_eq!(save_fault(&with_key(json!({ "room": "swarm", "event": "$e1", "path": "/etc" })), &on()), Some(Failure::Undecodable), "the reference is room and event only"); + assert_eq!(save_fault(&with_key(json!([{ "room": "swarm", "event": "$e1" }, { "room": "swarm", "event": "$e2" }])), &on()), Some(Failure::Undecodable), "one per reply: the field is an object"); +} + +#[test] +fn the_flag_is_exactly_one() { + let args = ["--rooms", "swarm", "--operator", "@op:chat", "--minds", "@eureka:chat", "--self-sender", "@eureka:chat"].map(String::from).to_vec(); + for (value, expected) in [(Some("1"), true), (Some("0"), false), (Some("true"), false), (Some(""), false), (Some(" 1"), false), (Some("11"), false), (None, false)] { + let lookup = move |name: &str| match name { + ENV_MODEL => Some("m".to_string()), + "BIFROST_CHAT_FETCH" => value.map(String::from), + _ => None, + }; + assert_eq!(Config::from_args_and_env(&args, lookup, "usage").unwrap().0.fetch, expected, "{value:?}"); + } +} + +#[test] +fn the_fetch_is_never_asked_on_the_poll_path() { + let (rig, store, _) = fetching(crate::chat_client::FetchOutcome::Stored, None, &[says(&[("swarm", "hello")])]); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert!(rig.chat.fetches.borrow().is_empty()); + assert!(!rig.log.borrow().iter().any(|step| step == "fetch")); +} + +#[test] +fn a_saved_image_from_an_alias_lands_by_hash_with_the_batch_request_id_and_one_line() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, image) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + let asked = rig.chat.fetches.borrow().clone(); + assert_eq!(asked, [crate::chat_client::ChatFetchRequest { request_id: "s1-1-a".into(), persona: INSTANCE.into(), room: "swarm".into(), event_id: "$pic".into() }]); + let name = format!("{}.png", image.sha256); + assert_eq!(kept(&rig), [name.clone()]); + assert_eq!(std::fs::read(rig.sandbox().join("attachments").join(&name)).unwrap(), PNG_BYTES); + let line = input_of(&rig, 1); + assert!(line.contains(&format!("saved at attachments/{name}")) && line.contains("The room shows you received it"), "{line}"); + assert!(!line.contains("$pic") && !line.contains("!swarm:chat") && !line.contains("CANARY"), "no event id, room id or mouth text: {line}"); + let trace = rig.trace.0.borrow(); + assert!(trace[0].outputs.iter().any(|item| item.text == "save_attachment swarm $pic: Stored"), "{trace:?}"); +} + +#[test] +fn the_three_saved_lines_are_their_own_and_a_stored_with_no_receipt_is_not_a_failure() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let mut told = Vec::new(); + for (outcome, receipt, word, says_not_read) in [(F::Stored, Some(R::Posted), "Stored", false), (F::Stored, Some(R::Existing), "Stored", false), (F::Stored, None, "StoredNoReceipt", true), (F::StoredUnverified, None, "StoredUnverified", true)] { + let (rig, store, image) = fetching(outcome, receipt, &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + let Outcome::Served { failure, .. } = rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() }) else { panic!("served") }; + assert_eq!(failure, None, "{outcome:?} {receipt:?}"); + assert_eq!(kept(&rig), [format!("{}.png", image.sha256)], "the file is saved under the computed hash for every saved outcome"); + let line = input_of(&rig, 1); + assert_eq!(line.contains("not marked read"), says_not_read, "{outcome:?} {receipt:?}: {line}"); + assert_eq!(line.contains("not verified"), outcome == F::StoredUnverified, "{line}"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {word}"))); + let tail = line.rsplit("notice role=organ").next().unwrap().to_string(); + told.push(tail.replace(&image.sha256, "H")); + } + assert_eq!(told[0], told[1], "Posted and Existing share one line"); + assert!(told[0] != told[2] && told[0] != told[3] && told[2] != told[3], "{told:?}"); +} + +#[test] +fn a_refusal_outcome_writes_no_file_and_is_told_in_its_own_line() { + use crate::chat_client::FetchOutcome as F; + let mut lines = BTreeSet::new(); + for outcome in [F::NotAMember, F::BridgedRoom, F::TooLarge, F::TypeRefused, F::NoAttachment, F::HashMismatch, F::RateLimited, F::Brake, F::PersonaMismatch, F::UnknownPersona, F::FetchFailed, F::StoreFailed] { + let (rig, store, _) = fetching(outcome, None, &[saving("swarm", "$pic"), says(&[])]); + let rig = { + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + rig + }; + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None, "{outcome:?}: told, never a failed step"); + assert!(kept(&rig).is_empty(), "{outcome:?} saved a file"); + let line = input_of(&rig, 1); + let told = line.rsplit("notice role=organ").next().unwrap().to_string(); + assert!(told.contains("was not saved") && !told.contains("CANARY") && !told.contains("$pic"), "{outcome:?}: {told}"); + assert!(lines.insert(told), "{outcome:?} shares a line with another"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {}", outcome.name()))); + } + assert_eq!(lines.len(), 12); +} + +#[test] +fn an_old_mouth_a_silent_mouth_and_no_store_are_told_and_not_retried() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + for (answer, told, word, sent) in [(FetchAnswer::Unserved, "does not serve attachments", "Unserved", 1), (FetchAnswer::Down, "did not answer", "Unanswered", 1)] { + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + *rig.chat.fetch_answer.borrow_mut() = answer; + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert_eq!(rig.chat.fetches.borrow().len(), sent, "no retry in the turn"); + let line = input_of(&rig, 1); + assert!(line.contains(told) && !line.contains("CANARY"), "{line}"); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == format!("save_attachment swarm $pic: {word}"))); + assert!(kept(&rig).is_empty()); + } + // No image store: decided before anything is sent. + let (rig, _, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&on())), None); + assert!(rig.chat.fetches.borrow().is_empty(), "nothing leaves the organ without a store"); + assert!(input_of(&rig, 1).contains("this Persona has no image store")); + assert!(rig.trace.0.borrow()[0].outputs.iter().any(|item| item.text == "save_attachment swarm $pic: NoStore")); +} + +#[test] +fn a_receipt_path_under_another_rooms_hash_or_a_missing_file_is_told_by_the_verifiers_reason_and_saves_nothing() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic"), says(&[])]); + let other = stored_image(store.path(), "!other:chat", PNG_BYTES); + let FetchAnswer::Receipt(receipt) = rig.chat.fetch_answer.borrow().clone() else { unreachable!() }; + *rig.chat.fetch_answer.borrow_mut() = FetchAnswer::Receipt(crate::chat_client::ChatFetchReceipt { image: Some(other), ..receipt }); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + assert!(kept(&rig).is_empty()); + assert!(input_of(&rig, 1).contains("the image reference leaves this room's store")); +} + +#[test] +fn the_gate_holds_a_fetch_as_it_holds_a_say() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[saving("swarm", "$pic")]); + let control = rig.store(); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + ControlWriter::open(&control).unwrap().set_brake(false, at(0), "op").unwrap(); + })); + let Outcome::Served { failure, .. } = rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() }) else { panic!("served") }; + assert_eq!(failure, Some(Failure::Held(HeldReason::BrakeHeld))); + assert!(rig.chat.fetches.borrow().is_empty()); + assert!(kept(&rig).is_empty()); +} + +#[test] +fn both_notices_survive_a_reply_with_open_direct_and_save_attachment() { + use crate::chat_client::{FetchOutcome as F, ReceiptMode as R}; + let mut both: serde_json::Value = serde_json::from_str(&opening(&[], "@ann:chat", "Hello Ann")).unwrap(); + both["save_attachment"] = json!({ "room": "swarm", "event": "$pic" }); + let (rig, store, _) = fetching(F::Stored, Some(R::Posted), &[both.to_string(), says(&[])]); + let (deliveries, pushed) = (rig.chat.deliveries.clone(), Cell::new(false)); + *rig.model.during.borrow_mut() = Some(Box::new(move || { + if !pushed.replace(true) { + deliveries.borrow_mut().push(delivery(2, "swarm", "@ann:chat", "any news?")); + } + })); + assert_eq!(failure_of(rig.go_with(&Config { image_store: Some(store.path().to_path_buf()), ..on() })), None); + let log = rig.log.borrow().clone(); + let (open, fetch) = (log.iter().position(|step| step == "open"), log.iter().position(|step| step == "fetch")); + assert!(open.is_some() && fetch.is_some() && open < fetch, "one request each, the open first: {log:?}"); + assert_eq!(log.iter().filter(|step| *step == "open" || *step == "fetch").count(), 2); + let line = input_of(&rig, 1); + let (open, save) = (line.find("Your invitation to @ann:chat").expect("the open's line"), line.find("is saved at attachments/").expect("the save's line")); + assert!(open < save, "the open's line first: {line}"); + assert_eq!(line.matches("notice role=organ").count(), 2); + let trace = rig.trace.0.borrow(); + let words: Vec<_> = trace[0].outputs.iter().filter(|item| item.text.starts_with("open_direct") || item.text.starts_with("save_attachment")).map(|item| item.text.clone()).collect(); + assert_eq!(words, ["open_direct @ann:chat: Opened", "save_attachment swarm $pic: Stored"]); + assert_eq!(rig.chat.fetches.borrow()[0].request_id, "s1-1-a"); + assert_eq!(rig.chat.opens.borrow()[0].request_id, "s1-1-d"); +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 1b6f104..2568fd7 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -236,7 +236,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str 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] , may_open : bool) -> String +mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , may_open : bool , fetch : bool) -> 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 >) > @@ -246,6 +246,7 @@ mind_body::persona: impl FileTrace: fn new (path : impl Into < PathBuf >) -> Sel 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 Saved mind_body::persona: impl Sender mind_body::persona: impl Shown mind_body::persona: impl Trace for FileTrace @@ -259,14 +260,15 @@ mind_body::persona: impl< C : ControlSource , S : ControlSource > SessionInbox f 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 ; -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 >, image_store: Option < PathBuf >] +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 >, image_store: Option < PathBuf >, fetch: bool] mind_body::persona: struct FileTrace pub-fields[] mind_body::persona: struct HuginnPersona pub-fields[] mind_body::persona: struct OpenDirect # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[with: String, text: String] 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, heartbeat: Option < & 'a dyn HeartbeatView >, 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[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >] +mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >, save_attachment: Option < Option < SaveAttachment > >] 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 Rule # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[by: String, words: String, repo: String, campaign: String, title: String, ruling: String] +mind_body::persona: struct SaveAttachment # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, event: String] 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 >]