huskies: merge 1206 story fleet_identity MCP tool: read sled pins vs live signed identity, and re-pin via TOFU

This commit is contained in:
Huskies Agent
2026-07-18 01:14:14 +00:00
parent 8d2ad6424b
commit fba9b09d3e
5 changed files with 956 additions and 2 deletions
+137
View File
@@ -29,6 +29,8 @@ const GATEWAY_TOOLS: &[&str] = &[
"prompt_permission",
// One-shot container rebuild: build fresh image, swap container, preserve state.
"project_rebuild",
// Read sled identity pins vs. live signed identity, and TOFU re-pin.
"fleet_identity",
];
/// Gateway tool definitions.
@@ -154,6 +156,24 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
"required": ["name"]
}
}),
json!({
"name": "fleet_identity",
"description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["read", "repin"],
"description": "\"read\" (default) reports every sled's pin vs. live identity. \"repin\" re-pins one sled via TOFU; requires `project`."
},
"project": {
"type": "string",
"description": "Required when action is \"repin\": the project/sled name to re-pin."
}
}
}
}),
]
}
@@ -420,6 +440,7 @@ async fn handle_gateway_tool(
"agents.list" => handle_agents_list_tool(id),
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
"fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
}
}
@@ -922,6 +943,67 @@ async fn handle_project_rebuild_tool(
)
}
/// Handle the `fleet_identity` gateway tool.
///
/// Dispatches on the `action` argument: `"read"` (default) reports every
/// sled's pin vs. live signed identity; `"repin"` captures and persists a
/// single sled's live verified identity via TOFU.
async fn handle_fleet_identity_tool(
params: &Value,
state: &GatewayState,
id: Option<Value>,
) -> JsonRpcResponse {
let args = params.get("arguments").unwrap_or(params);
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("read");
match action {
"read" => {
let reports = gateway::fleet_identity_read(state).await;
let text = gateway::format_identity_reports(&reports);
let reports_json = serde_json::to_value(&reports).unwrap_or(json!([]));
JsonRpcResponse::success(
id,
json!({
"content": [{ "type": "text", "text": text }],
"reports": reports_json,
}),
)
}
"repin" => {
let project = args.get("project").and_then(|v| v.as_str()).unwrap_or("");
if project.is_empty() {
return JsonRpcResponse::error(
id,
-32602,
"missing required parameter for action=\"repin\": project".into(),
);
}
match gateway::fleet_identity_repin(state, project).await {
Ok(node_id) => JsonRpcResponse::success(
id,
json!({
"content": [{
"type": "text",
"text": format!(
"Re-pinned `{project}` to verified node_id `{node_id}`."
)
}]
}),
),
Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()),
}
}
other => JsonRpcResponse::error(
id,
-32602,
format!("unknown fleet_identity action \"{other}\"; expected \"read\" or \"repin\""),
),
}
}
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
/// shape expected by the gateway web UI:
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
@@ -1111,4 +1193,59 @@ mod tests {
"MCP path should report 'not a directory', got: {mcp_msg}"
);
}
// ── fleet_identity tool (story 1206) ─────────────────────────────────────
#[tokio::test]
async fn fleet_identity_read_default_action_lists_projects() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state(dir.path());
let params = json!({ "arguments": {} });
let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
assert!(resp.error.is_none(), "expected success: {:?}", resp.error);
let text = resp.result.unwrap()["content"][0]["text"]
.as_str()
.unwrap()
.to_string();
assert!(text.contains("test-project"));
}
#[tokio::test]
async fn fleet_identity_repin_missing_project_returns_error() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state(dir.path());
let params = json!({ "arguments": { "action": "repin" } });
let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
assert!(resp.error.is_some(), "expected error for missing project");
let msg = resp.error.unwrap().message;
assert!(
msg.contains("project"),
"expected 'project' in error, got: {msg}"
);
}
#[tokio::test]
async fn fleet_identity_repin_unknown_project_returns_error() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state(dir.path());
let params = json!({ "arguments": { "action": "repin", "project": "nonexistent" } });
let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
assert!(resp.error.is_some(), "expected error for unknown project");
}
#[tokio::test]
async fn fleet_identity_unknown_action_returns_error() {
let dir = tempfile::tempdir().unwrap();
let state = make_test_state(dir.path());
let params = json!({ "arguments": { "action": "bogus" } });
let resp = handle_fleet_identity_tool(&params, &state, Some(json!(1))).await;
assert!(resp.error.is_some(), "expected error for unknown action");
let msg = resp.error.unwrap().message;
assert!(msg.contains("unknown fleet_identity action"), "got: {msg}");
}
#[test]
fn fleet_identity_is_in_gateway_tools() {
assert!(GATEWAY_TOOLS.contains(&"fleet_identity"));
}
}