feat(gateway): hivectl gateway user management + fix htpasswdFile assertion
Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
This commit is contained in:
parent
25d2951d1e
commit
4bff450343
61 changed files with 1084 additions and 547 deletions
|
|
@ -139,21 +139,25 @@ impl MatrixBridge {
|
|||
Returns the new event id."
|
||||
)]
|
||||
async fn send_message(&self, Parameters(args): Parameters<SendMessageArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendMessage {
|
||||
room: args.room,
|
||||
body: args.body,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::SendMessage {
|
||||
room: args.room,
|
||||
body: args.body,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Open (or reuse) a direct message room with `user_id` \
|
||||
(@user:server) and post `body` to it."
|
||||
)]
|
||||
#[tool(description = "Open (or reuse) a direct message room with `user_id` \
|
||||
(@user:server) and post `body` to it.")]
|
||||
async fn send_dm(&self, Parameters(args): Parameters<SendDmArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendDm {
|
||||
user_id: args.user_id,
|
||||
body: args.body,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::SendDm {
|
||||
user_id: args.user_id,
|
||||
body: args.body,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -162,35 +166,40 @@ impl MatrixBridge {
|
|||
standard clients."
|
||||
)]
|
||||
async fn send_reaction(&self, Parameters(args): Parameters<SendReactionArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendReaction {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
key: args.key,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::SendReaction {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
key: args.key,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Reply to a specific matrix event in a room, threaded \
|
||||
via m.in_reply_to. Returns the reply's event id."
|
||||
)]
|
||||
#[tool(description = "Reply to a specific matrix event in a room, threaded \
|
||||
via m.in_reply_to. Returns the reply's event id.")]
|
||||
async fn send_reply(&self, Parameters(args): Parameters<SendReplyArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendReply {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
body: args.body,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::SendReply {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
body: args.body,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Mark a specific event as read for this agent. Updates \
|
||||
#[tool(description = "Mark a specific event as read for this agent. Updates \
|
||||
the room's unread indicator + sends a read receipt other \
|
||||
participants can see."
|
||||
)]
|
||||
participants can see.")]
|
||||
async fn mark_read(&self, Parameters(args): Parameters<MarkReadArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::MarkRead {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::MarkRead {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -209,28 +218,27 @@ impl MatrixBridge {
|
|||
render(round_trip(DaemonRequest::ListRoomMembers { room: args.room }).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Read the most recent N events from a matrix room \
|
||||
#[tool(description = "Read the most recent N events from a matrix room \
|
||||
(default 50, max 200). Returns each event's id, sender, timestamp, \
|
||||
type, and best-effort plain-text body."
|
||||
)]
|
||||
type, and best-effort plain-text body.")]
|
||||
async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::ReadRoom {
|
||||
room: args.room,
|
||||
limit: args.limit,
|
||||
}).await)
|
||||
render(
|
||||
round_trip(DaemonRequest::ReadRoom {
|
||||
room: args.room,
|
||||
limit: args.limit,
|
||||
})
|
||||
.await,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(
|
||||
instructions = "Matrix client for an agent on a hyperhive swarm. Use \
|
||||
#[tool_handler(instructions = "Matrix client for an agent on a hyperhive swarm. Use \
|
||||
`send_message` to post in a joined room, `send_dm` to message a \
|
||||
specific user, `send_reaction` to react with an emoji, `send_reply` \
|
||||
to thread a reply, `mark_read` to acknowledge an event. Discover \
|
||||
rooms with `list_rooms`, members with `list_room_members`, recent \
|
||||
timeline with `read_room`. Room references accept ids (!abc:server) \
|
||||
or aliases (#name:server); user references use @user:server."
|
||||
)]
|
||||
or aliases (#name:server); user references use @user:server.")]
|
||||
impl ServerHandler for MatrixBridge {}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -258,7 +266,13 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
let bridge = MatrixBridge::new();
|
||||
let service = bridge.serve(stdio()).await.context("serve MCP over stdio")?;
|
||||
service.waiting().await.context("MCP service exited unexpectedly")?;
|
||||
let service = bridge
|
||||
.serve(stdio())
|
||||
.await
|
||||
.context("serve MCP over stdio")?;
|
||||
service
|
||||
.waiting()
|
||||
.await
|
||||
.context("MCP service exited unexpectedly")?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,7 @@ pub async fn build_and_restore(
|
|||
.trim()
|
||||
.to_owned();
|
||||
if token.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"matrix token at {} is empty",
|
||||
token_file.display()
|
||||
));
|
||||
return Err(anyhow!("matrix token at {} is empty", token_file.display()));
|
||||
}
|
||||
|
||||
let (user_id, device_id) = whoami(homeserver, &token).await?;
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ use matrix_sdk::{
|
|||
OwnedEventId, OwnedRoomId, OwnedUserId, RoomOrAliasId,
|
||||
api::client::receipt::create_receipt::v3::ReceiptType,
|
||||
events::{
|
||||
receipt::ReceiptThread,
|
||||
reaction::ReactionEventContent,
|
||||
receipt::ReceiptThread,
|
||||
relation::Annotation,
|
||||
room::message::{MessageType, RoomMessageEventContent},
|
||||
},
|
||||
|
|
@ -64,17 +64,20 @@ async fn resolve_room(
|
|||
client: &Client,
|
||||
reference: &str,
|
||||
) -> Result<matrix_sdk::Room, DaemonResponse> {
|
||||
let parsed: &RoomOrAliasId = reference.try_into().map_err(|e| {
|
||||
DaemonResponse::error(format!("invalid room reference {reference}: {e}"))
|
||||
})?;
|
||||
let parsed: &RoomOrAliasId = reference
|
||||
.try_into()
|
||||
.map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?;
|
||||
let room_id: OwnedRoomId = if parsed.is_room_id() {
|
||||
OwnedRoomId::try_from(reference)
|
||||
.map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))?
|
||||
} else {
|
||||
client
|
||||
.resolve_room_alias(parsed.as_str().try_into().map_err(|e| {
|
||||
DaemonResponse::error(format!("invalid alias: {e}"))
|
||||
})?)
|
||||
.resolve_room_alias(
|
||||
parsed
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?,
|
||||
)
|
||||
.await
|
||||
.map(|r| r.room_id)
|
||||
.map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))?
|
||||
|
|
@ -93,16 +96,14 @@ async fn resolve_room(
|
|||
fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String {
|
||||
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
|
||||
match event {
|
||||
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => {
|
||||
ev.as_original().map_or_else(String::new, |orig| {
|
||||
match &orig.content.msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(n) => n.body.clone(),
|
||||
MessageType::Emote(e) => format!("* {}", e.body),
|
||||
_ => String::new(),
|
||||
}
|
||||
})
|
||||
}
|
||||
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev
|
||||
.as_original()
|
||||
.map_or_else(String::new, |orig| match &orig.content.msgtype {
|
||||
MessageType::Text(t) => t.body.clone(),
|
||||
MessageType::Notice(n) => n.body.clone(),
|
||||
MessageType::Emote(e) => format!("* {}", e.body),
|
||||
_ => String::new(),
|
||||
}),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -128,14 +129,13 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon
|
|||
Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")),
|
||||
};
|
||||
// Find existing DM or create one.
|
||||
let room = client
|
||||
.joined_rooms()
|
||||
.into_iter()
|
||||
.find(|r| {
|
||||
// is_direct() is async; check direct_targets() instead which
|
||||
// reads from cached state.
|
||||
r.direct_targets().iter().any(|t| t.as_str() == uid.as_str())
|
||||
});
|
||||
let room = client.joined_rooms().into_iter().find(|r| {
|
||||
// is_direct() is async; check direct_targets() instead which
|
||||
// reads from cached state.
|
||||
r.direct_targets()
|
||||
.iter()
|
||||
.any(|t| t.as_str() == uid.as_str())
|
||||
});
|
||||
let room = match room {
|
||||
Some(r) => r,
|
||||
None => match client.create_dm(&uid).await {
|
||||
|
|
@ -268,16 +268,18 @@ pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonRespons
|
|||
}
|
||||
|
||||
pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) -> DaemonResponse {
|
||||
use matrix_sdk::ruma::api::client::message::get_message_events;
|
||||
use matrix_sdk::ruma::api::Direction;
|
||||
use matrix_sdk::ruma::api::client::message::get_message_events;
|
||||
|
||||
let room = match resolve_room(client, room_ref).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let limit = limit.unwrap_or(50).min(200);
|
||||
let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
|
||||
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64).unwrap_or(matrix_sdk::ruma::UInt::from(50u32));
|
||||
let mut req =
|
||||
get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
|
||||
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64)
|
||||
.unwrap_or(matrix_sdk::ruma::UInt::from(50u32));
|
||||
let resp = match client.send(req).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ pub fn homeserver_url() -> String {
|
|||
/// `HIVE_MATRIX_SOCKET`; default is `/run/hive-matrix/socket`.
|
||||
#[must_use]
|
||||
pub fn daemon_socket() -> PathBuf {
|
||||
std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
|
||||
std::env::var_os("HIVE_MATRIX_SOCKET")
|
||||
.map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
|
||||
}
|
||||
|
||||
/// Persistent sqlite store directory for matrix-sdk's state (event
|
||||
|
|
@ -62,5 +63,6 @@ pub fn matrix_state_dir() -> PathBuf {
|
|||
/// Mirrors the path `forge_notify` writes to.
|
||||
#[must_use]
|
||||
pub fn hyperhive_socket() -> PathBuf {
|
||||
std::env::var_os("HIVE_CONTROL_SOCKET").map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from)
|
||||
std::env::var_os("HIVE_CONTROL_SOCKET")
|
||||
.map_or_else(|| PathBuf::from("/run/hive/mcp.sock"), PathBuf::from)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ mod tests {
|
|||
#[test]
|
||||
fn format_wake_body_short_passes_through() {
|
||||
let body = format_wake_body("@iris:matrix.darkest.space", "#general", "hi all");
|
||||
assert_eq!(body, "[matrix] @iris:matrix.darkest.space in #general: hi all");
|
||||
assert_eq!(
|
||||
body,
|
||||
"[matrix] @iris:matrix.darkest.space in #general: hi all"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue