Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
//! Text from outside (model output, tool arguments, server errors) made safe to show: control
|
||||
//! characters, zero-width and bidirectional marks, line separators and the BOM become `\uXXXX`.
|
||||
//! Used by `bxctl` for the terminal and by `gatewayd` for Mattermost, so both show the same.
|
||||
|
||||
// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code
|
||||
// point in any of these ranges must never reach the terminal raw.
|
||||
const ESCAPE_RANGES: &[(u32, u32)] = &[
|
||||
(0x0000, 0x001f),
|
||||
(0x007f, 0x009f),
|
||||
(0x200b, 0x200f),
|
||||
(0x2028, 0x202e),
|
||||
(0x2060, 0x2069),
|
||||
(0xfeff, 0xfeff),
|
||||
];
|
||||
|
||||
pub fn escape_json_text(s: &str) -> String {
|
||||
escape(s, false)
|
||||
}
|
||||
|
||||
pub fn escape_model_text(s: &str) -> String {
|
||||
escape(s, true)
|
||||
}
|
||||
|
||||
fn escape(s: &str, model_text: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
let cp = u32::from(c);
|
||||
if should_escape(c, model_text) {
|
||||
out.push_str(&format!("\\u{:04x}", cp));
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// A control point is escaped, except that model text keeps \n and \t.
|
||||
fn should_escape(c: char, model_text: bool) -> bool {
|
||||
let cp = u32::from(c);
|
||||
if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) {
|
||||
!(model_text && (cp == 0x09 || cp == 0x0a))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The messages on `gateway.sock`, between `brokerd` and `gatewayd`: one connection carries one
|
||||
//! pending approval to Mattermost and its answer back (M4b spec, section 4).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::wire::DenyReason;
|
||||
use crate::wire::PendingApproval;
|
||||
|
||||
/// The most characters a refusal's reason may have.
|
||||
pub const MAX_REASON: usize = 500;
|
||||
|
||||
/// `brokerd` → `gatewayd`, first: the approval to post.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApprovalRequest {
|
||||
pub approval: PendingApproval,
|
||||
}
|
||||
|
||||
/// `gatewayd` → `brokerd`: it is posted, in these posts, first part first.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApprovalPosted {
|
||||
pub posts: Vec<String>,
|
||||
}
|
||||
|
||||
/// `gatewayd` → `brokerd`: an approver answered, with this post.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApprovalReply {
|
||||
pub approval: u64,
|
||||
pub approved: bool,
|
||||
pub by: String,
|
||||
pub post: String,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// `brokerd` → `gatewayd`, last: what came of the answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApprovalOutcome {
|
||||
pub outcome: GatewayOutcome,
|
||||
}
|
||||
|
||||
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum GatewayOutcome {
|
||||
/// Approved, and the grants as they are now still allow it.
|
||||
Runs {},
|
||||
/// Approved, but denied on the re-check, or the answer could not be recorded.
|
||||
Denied { reason: DenyReason },
|
||||
/// The refusal is recorded.
|
||||
Refused {},
|
||||
/// It was no longer pending when the answer came.
|
||||
Gone {},
|
||||
}
|
||||
|
||||
/// A Mattermost id: 26 characters of `a-z0-9`.
|
||||
pub fn is_mattermost_id(id: &str) -> bool {
|
||||
// `id.len() == 26` and every byte `b.is_ascii_lowercase() || b.is_ascii_digit()`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
impl ApprovalReply {
|
||||
/// `by` and `post` are Mattermost ids and the reason is at most `MAX_REASON` characters; the
|
||||
/// first problem otherwise.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
// In this order, each an Err with exactly this text:
|
||||
// 1. `!is_mattermost_id(&self.by)`: "the answer's `by` is not a Mattermost id".
|
||||
// 2. `!is_mattermost_id(&self.post)`: "the answer's `post` is not a Mattermost id".
|
||||
// 3. a reason with more than `MAX_REASON` characters (`r.chars().count()`, not bytes):
|
||||
// `format!("the reason is over {MAX_REASON} characters")`.
|
||||
// 4. Ok(()).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user