hive-c0re/hive-sh4re: remove the ask/answer wire protocol + core routing
This commit is contained in:
parent
46183795dd
commit
2850270829
23 changed files with 177 additions and 851 deletions
|
|
@ -208,37 +208,12 @@ pub(crate) async fn dispatch_shared(
|
|||
}
|
||||
hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body),
|
||||
hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit),
|
||||
hive_core_agent_sock::Request::Ask {
|
||||
question,
|
||||
options,
|
||||
multi,
|
||||
ttl_seconds,
|
||||
to,
|
||||
} => crate::questions::handle_ask(
|
||||
coord,
|
||||
agent,
|
||||
question,
|
||||
options,
|
||||
*multi,
|
||||
*ttl_seconds,
|
||||
to.as_ref().map(hive_types::Ident::as_str),
|
||||
)
|
||||
.map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|id| hive_core_agent_sock::Response::QuestionQueued { id },
|
||||
),
|
||||
hive_core_agent_sock::Request::Answer { id, answer } => {
|
||||
crate::questions::handle_answer(coord, agent, *id, answer).map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|()| hive_core_agent_sock::Response::Ok,
|
||||
)
|
||||
}
|
||||
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
|
||||
hive_core_agent_sock::Request::GetAgentMeta { name } => {
|
||||
handle_get_agent_meta(coord, agent, name.as_ref()).await
|
||||
}
|
||||
hive_core_agent_sock::Request::CancelLooseEnd { kind, id } => {
|
||||
crate::questions::handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|
||||
handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|()| hive_core_agent_sock::Response::Ok,
|
||||
)
|
||||
|
|
@ -790,6 +765,97 @@ fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str, target: Option<&
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
|
||||
/// with its own auth check: reminder cancels are handled entirely in-container
|
||||
/// (this arm should be unreachable in practice, kept only so the match stays
|
||||
/// exhaustive) and approval cancels require the `approvals` tool-group (the
|
||||
/// grantable capability) AND ownership — the canceller must be the approval's
|
||||
/// submitter — so no positional / hardcoded privilege and no cross-agent
|
||||
/// cancellation. (The operator's cancel-anything path is a separate handler.)
|
||||
fn handle_cancel_loose_end(
|
||||
coord: &Arc<Coordinator>,
|
||||
canceller: &str,
|
||||
kind: hive_sh4re::inbox::CancelLooseEndKind,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
match kind {
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Reminder => {
|
||||
// Reminders are now agent-local (in-container store) — the
|
||||
// agent-mcp `cancel_loose_end` tool branches on this kind and
|
||||
// dials the agent's own socket directly, never forwarding to
|
||||
// hive-c0re. This arm should be unreachable in practice; kept
|
||||
// only so the match stays exhaustive.
|
||||
Err(format!(
|
||||
"reminder {id}: reminders are handled locally by the agent, \
|
||||
not by hive-c0re"
|
||||
))
|
||||
}
|
||||
hive_sh4re::inbox::CancelLooseEndKind::Approval => {
|
||||
// Withdrawing an approval needs the grantable `approvals`
|
||||
// tool-group (held by any approval-submitting orchestrator)
|
||||
// AND ownership: only the agent that submitted the approval
|
||||
// may withdraw it. Without the ownership check, any
|
||||
// approvals-group agent could cancel any other's approval by
|
||||
// id. A NULL submitter (legacy row predating the column) is
|
||||
// treated as operator-initiated (no agent tracking predates
|
||||
// the column).
|
||||
check_can_cancel_approval(canceller)?;
|
||||
let submitter = coord
|
||||
.approvals
|
||||
.submitter_of(id)
|
||||
.map_err(|e| format!("{e:#}"))?
|
||||
.unwrap_or_else(|| "operator".to_owned());
|
||||
if submitter != canceller {
|
||||
return Err(format!(
|
||||
"cancel_loose_end: approval {id} was submitted by {submitter}, \
|
||||
not {canceller}; only the submitting agent can withdraw it"
|
||||
));
|
||||
}
|
||||
let approval = coord
|
||||
.approvals
|
||||
.mark_cancelled(id, canceller)
|
||||
.map_err(|e| format!("{e:#}"))?;
|
||||
tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled");
|
||||
let sha_short = approval
|
||||
.fetched_sha
|
||||
.as_deref()
|
||||
.map(|s| s[..s.len().min(12)].to_owned());
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id: approval.id,
|
||||
agent: approval.agent.as_str(),
|
||||
approval_kind: approval.kind.as_str(),
|
||||
sha_short,
|
||||
status: "cancelled",
|
||||
note: approval.note,
|
||||
description: approval.description,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability guard on the `Approval` cancel arm: the caller must hold the
|
||||
/// `approvals` tool-group (the grantable capability for approval-submitting
|
||||
/// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
|
||||
/// out so the auth check has its own focused unit test — exercising the full
|
||||
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
|
||||
/// sqlite). Keys on a grantable capability, not a positional / hardcoded
|
||||
/// privilege.
|
||||
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
|
||||
const APPROVALS_GROUP: &str = "approvals";
|
||||
if crate::tool_groups::groups_for(canceller)
|
||||
.iter()
|
||||
.any(|g| g == APPROVALS_GROUP)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules:
|
||||
///
|
||||
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
|
||||
|
|
@ -1077,43 +1143,18 @@ async fn handle_get_logs(agent: &str, lines: Option<u32>) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
/// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to
|
||||
/// resolve the question with `[expired]`. If the operator (or any
|
||||
/// other path) already answered it, `answer()` returns Err and we
|
||||
/// no-op silently. Otherwise fire a `QuestionAnswered` helper event
|
||||
/// with `answerer = "ttl-watchdog"` so the asker can distinguish a
|
||||
/// real answer from a deadline trip without parsing the answer text.
|
||||
const TTL_SENTINEL: &str = "[expired]";
|
||||
/// Synthetic `answerer` label used when the ttl watchdog resolves a
|
||||
/// question instead of a real human / agent. Lives in a distinct
|
||||
/// namespace from agent names + the operator so the asker can pattern
|
||||
/// match `event.answerer == "ttl-watchdog"`.
|
||||
const TTL_ANSWERER: &str = "ttl-watchdog";
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) {
|
||||
let coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await;
|
||||
// Watchdog has its own answerer label so the authorisation
|
||||
// check in `answer()` permits it for any target. We bypass
|
||||
// the public `answer()` path by calling it with the operator
|
||||
// identity, since the operator is always permitted; the
|
||||
// event we fire carries the real watchdog label for observers.
|
||||
if let Ok((question, asker, _target)) =
|
||||
coord
|
||||
.questions
|
||||
.answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
{
|
||||
tracing::info!(%id, %asker, "question expired (ttl)");
|
||||
coord.notify_agent(
|
||||
&asker,
|
||||
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: TTL_SENTINEL.to_owned(),
|
||||
answerer: TTL_ANSWERER.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
#[test]
|
||||
fn approval_cancel_rejects_callers_without_the_approvals_group() {
|
||||
// A caller that doesn't hold the `approvals` tool-group must not be
|
||||
// able to cancel approval rows even if it invents an id. The guard is
|
||||
// server-side so client cooperation is irrelevant — and it keys on a
|
||||
// grantable capability (the tool-group), not on any agent name.
|
||||
// `groups_for` of a name with no tool_groups.json entry is empty.
|
||||
let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err();
|
||||
assert!(err.contains("approvals` tool group"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue