huskies: merge 898

This commit is contained in:
dave
2026-05-12 21:29:04 +00:00
parent d78dd9e8f9
commit 937792f208
10 changed files with 829 additions and 23 deletions
+3 -1
View File
@@ -22,4 +22,6 @@ pub use rest::{
gateway_bot_config_save_handler, gateway_generate_token_handler, gateway_mode_handler,
gateway_remove_project_handler,
};
pub use websocket::{gateway_crdt_sync_handler, gateway_event_push_handler};
pub use websocket::{
gateway_crdt_sync_handler, gateway_event_push_handler, gateway_sled_uplink_handler,
};
+128
View File
@@ -146,6 +146,134 @@ pub async fn gateway_crdt_sync_handler(
.into_response()
}
// ── Sled uplink WebSocket handler ────────────────────────────────────────────
/// Query parameters accepted on the `/api/sled-uplink` WebSocket upgrade.
#[derive(Deserialize)]
struct SledUplinkParams {
/// Shared-secret token identifying the connecting sled (from `[sled_tokens]` in `projects.toml`).
token: Option<String>,
}
/// `GET /api/sled-uplink` — gateway-side WebSocket endpoint for sled permission uplinks.
///
/// # Authentication
///
/// The connecting sled must supply a valid shared-secret token via the `token`
/// query parameter. Tokens are configured in `[sled_tokens]` in `projects.toml`
/// as `sled_id = "secret"` entries.
///
/// # Protocol
///
/// See `sled_uplink.rs` for the wire format ([`UplinkEnvelope`]). The gateway
/// accepts `perm_request` messages, injects them into the local permission
/// pipeline (via `state.perm_tx`), and sends `perm_response` frames back to the
/// sled once the Matrix bot resolves them. Multiple sleds are demuxed by
/// connection: each handler owns exactly one sled's request/response flow.
#[handler]
pub async fn gateway_sled_uplink_handler(
ws: WebSocket,
state: Data<&Arc<GatewayState>>,
Query(params): Query<SledUplinkParams>,
) -> poem::Response {
let token = match params.token {
Some(t) if !t.is_empty() => t,
_ => {
return poem::Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("token query parameter required");
}
};
let sled_id = match state.sled_tokens.get(&token) {
Some(id) => id.clone(),
None => {
return poem::Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("invalid token");
}
};
use poem::IntoResponse as _;
let perm_tx = state.perm_tx.clone();
ws.on_upgrade(move |socket| async move {
let (mut sink, mut stream) = socket.split();
// Aggregator channel: spawned per-request tasks send (req_id, decision) here
// so the main loop can write perm_response frames back to the sled.
let (agg_tx, mut agg_rx) = tokio::sync::mpsc::unbounded_channel::<(
String,
crate::http::context::PermissionDecision,
)>();
crate::slog!("[gateway/sled-uplink] Sled '{}' connected", sled_id);
loop {
tokio::select! {
msg = stream.next() => {
let text = match msg {
Some(Ok(WsMessage::Text(t))) => t,
Some(Ok(WsMessage::Close(_))) | None => break,
_ => continue,
};
let Ok(env) = serde_json::from_str::<crate::sled_uplink::UplinkEnvelope>(&text) else {
continue;
};
if env.msg_type == "perm_request" {
let req_id = env.req_id.clone();
let tool_name = env.payload.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let tool_input = env.payload.get("tool_input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let fwd = crate::http::context::PermissionForward {
request_id: format!("{sled_id}:{req_id}"),
tool_name,
tool_input,
response_tx,
};
if perm_tx.send(fwd).is_err() {
break;
}
let agg_tx2 = agg_tx.clone();
tokio::spawn(async move {
let decision = response_rx
.await
.unwrap_or(crate::http::context::PermissionDecision::Deny);
let _ = agg_tx2.send((req_id, decision));
});
}
}
Some((req_id, decision)) = agg_rx.recv() => {
use crate::http::context::PermissionDecision;
let (approved, always_allow) = match decision {
PermissionDecision::AlwaysAllow => (true, true),
PermissionDecision::Approve => (true, false),
PermissionDecision::Deny => (false, false),
};
let resp = crate::sled_uplink::UplinkEnvelope {
msg_type: "perm_response".to_string(),
req_id,
payload: serde_json::json!({
"approved": approved,
"always_allow": always_allow,
}),
};
let Ok(text) = serde_json::to_string(&resp) else { continue };
if sink.send(WsMessage::Text(text)).await.is_err() {
break;
}
}
}
}
crate::slog!("[gateway/sled-uplink] Sled '{}' disconnected", sled_id);
})
.into_response()
}
// ── Event-push WebSocket handler ─────────────────────────────────────────────
/// Query parameters accepted on the `/gateway/events/push` WebSocket upgrade.