ai_memory/handlers/skills.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers for the v0.7.0 skills surface (#650 follow-up
5//! per-domain split). Each handler is a thin Axum-layer wrapper that
6//! transforms request data into the canonical JSON params the
7//! underlying MCP `handle_skill_*` substrate functions expect, then
8//! shapes their `Result<Value, String>` into the appropriate HTTP
9//! status code.
10//!
11//! All handlers were extracted verbatim from `src/handlers/http.rs`
12//! (commit 88d9a96, lines 7591-7782); wire compatibility is preserved
13//! via the `pub use skills::*` re-export from `src/handlers/mod.rs`.
14//!
15//! # v0.7.0 #949 (Track A QC sweep, 2026-05-20) — admin-role gate on
16//! every skill route
17//!
18//! Pre-#949 none of the 7 routes accepted a `HeaderMap`, resolved the
19//! caller, or applied any cross-tenant gate. Skills are executable
20//! artefacts (SKILL.md + resources + signing surface) — the supply-
21//! chain attack surface is broader than a memory row:
22//!
23//! - register / promote / compose: WRITE surfaces that mint or
24//! re-mint executable capabilities. Cross-tenant write = forged
25//! provenance on a skill that other agents will subsequently
26//! activate.
27//! - export: WRITES to the daemon-host filesystem (target_folder
28//! resolved on the daemon, written under the daemon user). Cross-
29//! tenant export = arbitrary-path write surface from any caller.
30//! - list / get / resource: READ surfaces that exfiltrate skill
31//! bodies, manifests, and resource blobs (potentially tagged with
32//! another tenant's `signing_agent`).
33//!
34//! Posture: **admin-only across all 7 routes** via
35//! [`crate::handlers::admin_role::require_admin`]. This is the same
36//! shape #957 (`export_memories`) and #946 (`list_agents`) use for
37//! their corpus-scale admin surfaces. Skills don't carry a Memory-
38//! shaped `metadata.scope` / `metadata.agent_id` in the canonical
39//! `Memory` struct the `crate::visibility::is_visible_to_caller`
40//! helper operates on — the skill `signing_agent` column is only
41//! populated when the daemon boots with a keypair (the default install
42//! has none). A per-owner gate based on `signing_agent` would be open
43//! by default; the admin gate is closed by default. Per the v0.7.0
44//! safe-by-default posture, every skill HTTP surface MUST be admin-
45//! only until a future cluster lands a richer skill-ACL model.
46
47use crate::models::field_names;
48use axum::{
49 Json,
50 extract::{Path, Query, State},
51 http::{HeaderMap, StatusCode},
52 response::IntoResponse,
53};
54use serde::Deserialize;
55use serde_json::json;
56
57use super::AppState;
58
59/// Tracing target for the skills HTTP handlers (#1558 tracing-target SSOT).
60const SKILLS_TRACE_TARGET: &str = "ai_memory::handlers::skills";
61
62/// `POST /api/v1/skill` — register a new skill from an inline body.
63pub async fn skill_register_route(
64 State(app): State<AppState>,
65 headers: HeaderMap,
66 Json(body): Json<serde_json::Value>,
67) -> impl IntoResponse {
68 // #949 — admin-only. Skill registration mints an executable
69 // artefact; non-admin callers MUST NOT be able to plant a row
70 // other agents will subsequently activate.
71 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_register")
72 {
73 return resp;
74 }
75 let lock = app.db.lock().await;
76 let kp = (*app.active_keypair).as_ref();
77 match crate::mcp::handle_skill_register(&lock.0, &body, kp) {
78 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
79 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
80 }
81}
82
83/// `GET /api/v1/skill/list?namespace=<ns>&filter=<text>`.
84///
85/// Query params mirror the MCP `namespace` and `filter` keys.
86#[derive(Deserialize)]
87pub struct SkillListQuery {
88 pub namespace: Option<String>,
89 pub filter: Option<String>,
90}
91
92pub async fn skill_list_route(
93 State(app): State<AppState>,
94 headers: HeaderMap,
95 Query(q): Query<SkillListQuery>,
96) -> impl IntoResponse {
97 // #949 — admin-only. The list payload enumerates every skill in
98 // the requested namespace including bodies that may be tagged
99 // with another tenant's `signing_agent`. Cross-tenant
100 // enumeration of executable artefacts is a supply-chain probe
101 // vector.
102 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_list") {
103 return resp;
104 }
105 let mut params = json!({});
106 if let Some(ns) = q.namespace {
107 params["namespace"] = json!(ns);
108 }
109 if let Some(f) = q.filter {
110 params["filter"] = json!(f);
111 }
112 let lock = app.db.lock().await;
113 match crate::mcp::handle_skill_list(&lock.0, ¶ms) {
114 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
115 Err(e) => {
116 // #1261 — never forward the raw substrate error (often a
117 // `rusqlite::Error` string carrying SQL fragments) on the
118 // HTTP wire. Log the raw text for operators, surface a
119 // generic "internal server error" to the caller.
120 tracing::error!(
121 target: SKILLS_TRACE_TARGET,
122 error = %e,
123 "skill_list_route: substrate error (sanitized for wire response, #1261)"
124 );
125 (
126 StatusCode::INTERNAL_SERVER_ERROR,
127 Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
128 )
129 .into_response()
130 }
131 }
132}
133
134/// `GET /api/v1/skill/{id}` — full activation payload (body included).
135pub async fn skill_get_route(
136 State(app): State<AppState>,
137 headers: HeaderMap,
138 Path(id): Path<String>,
139) -> impl IntoResponse {
140 // #949 — admin-only. The GET response includes the full
141 // (decompressed) skill body — the executable capability bundle.
142 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_get") {
143 return resp;
144 }
145 let params = json!({"skill_id": id});
146 let lock = app.db.lock().await;
147 match crate::mcp::handle_skill_get(&lock.0, ¶ms) {
148 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
149 Err(e) => {
150 // Substrate uses a "skill not found:" prefix for the missing
151 // case; surface that as 404. Everything else is 500.
152 if e.starts_with(crate::errors::msg::SKILL_NOT_FOUND) {
153 (StatusCode::NOT_FOUND, Json(json!({"error": e}))).into_response()
154 } else {
155 // #1261 — never forward the raw substrate error (often
156 // a `rusqlite::Error` string carrying SQL fragments) on
157 // the HTTP wire. Log the raw text; emit a generic
158 // "internal server error" to the caller.
159 tracing::error!(
160 target: SKILLS_TRACE_TARGET,
161 error = %e,
162 "skill_get_route: substrate error (sanitized for wire response, #1261)"
163 );
164 (
165 StatusCode::INTERNAL_SERVER_ERROR,
166 Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
167 )
168 .into_response()
169 }
170 }
171 }
172}
173
174/// `GET /api/v1/skill/{id}/resource?path=<resource_path>`.
175#[derive(Deserialize)]
176pub struct SkillResourceQuery {
177 pub path: String,
178}
179
180pub async fn skill_resource_route(
181 State(app): State<AppState>,
182 headers: HeaderMap,
183 Path(id): Path<String>,
184 Query(q): Query<SkillResourceQuery>,
185) -> impl IntoResponse {
186 // #949 — admin-only. Skill resource blobs are part of the
187 // executable bundle (scripts, prompts, fixtures) and inherit
188 // the same supply-chain threat surface as the skill body.
189 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_resource")
190 {
191 return resp;
192 }
193 let params = json!({
194 "skill_id": id,
195 (field_names::RESOURCE_PATH): q.path,
196 });
197 let lock = app.db.lock().await;
198 match crate::mcp::handle_skill_resource(&lock.0, ¶ms) {
199 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
200 Err(e) => {
201 if e.starts_with("resource not found") {
202 (StatusCode::NOT_FOUND, Json(json!({"error": e}))).into_response()
203 } else {
204 (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response()
205 }
206 }
207 }
208}
209
210/// `POST /api/v1/skill/{id}/export`.
211///
212/// Body: `{ "target_folder": "<path>" }`. The path is resolved on the
213/// daemon host, so the operator must ensure it's writable by the
214/// daemon user.
215#[derive(Deserialize)]
216pub struct SkillExportBody {
217 pub target_folder: String,
218}
219
220pub async fn skill_export_route(
221 State(app): State<AppState>,
222 headers: HeaderMap,
223 Path(id): Path<String>,
224 Json(body): Json<SkillExportBody>,
225) -> impl IntoResponse {
226 // #949 — admin-only. Export writes `target_folder` on the daemon
227 // host (resolved by the daemon, written under the daemon user);
228 // any non-admin caller would gain an arbitrary-path write
229 // primitive on the host filesystem. Same admin-class shape as
230 // #957 (`export_memories`).
231 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_export") {
232 return resp;
233 }
234 let params = json!({
235 "skill_id": id,
236 (field_names::TARGET_FOLDER): body.target_folder,
237 });
238 let lock = app.db.lock().await;
239 let kp = (*app.active_keypair).as_ref();
240 match crate::mcp::handle_skill_export(&lock.0, ¶ms, kp) {
241 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
242 Err(e) => {
243 if e.starts_with(crate::errors::msg::SKILL_NOT_FOUND) {
244 (StatusCode::NOT_FOUND, Json(json!({"error": e}))).into_response()
245 } else {
246 (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response()
247 }
248 }
249 }
250}
251
252/// `POST /api/v1/skill/{id}/promote`.
253///
254/// Path `{id}` is the source **reflection** id (not a skill id — the
255/// promote verb consumes a reflection and produces a skill). Body
256/// carries the new skill's `name`, `description`, and optional
257/// `parameters_schema`.
258#[derive(Deserialize)]
259pub struct SkillPromoteBody {
260 pub name: String,
261 pub description: String,
262 pub parameters_schema: Option<serde_json::Value>,
263}
264
265pub async fn skill_promote_route(
266 State(app): State<AppState>,
267 headers: HeaderMap,
268 Path(id): Path<String>,
269 Json(body): Json<SkillPromoteBody>,
270) -> impl IntoResponse {
271 // #949 — admin-only. Promote consumes a reflection memory and
272 // mints a new skill row carrying the promoting agent's signing
273 // surface. Cross-tenant promote = laundering an executable
274 // capability through someone else's reflection.
275 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_promote") {
276 return resp;
277 }
278 let mut params = json!({
279 (field_names::REFLECTION_ID): id,
280 (field_names::SKILL_NAME): body.name,
281 (field_names::SKILL_DESCRIPTION): body.description,
282 });
283 if let Some(ps) = body.parameters_schema {
284 params[field_names::PARAMETERS_SCHEMA] = ps;
285 }
286 let lock = app.db.lock().await;
287 let kp = (*app.active_keypair).as_ref();
288 match crate::mcp::handle_skill_promote_from_reflection(&lock.0, ¶ms, kp) {
289 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
290 Err(e) => {
291 if e.contains("not found") {
292 (StatusCode::NOT_FOUND, Json(json!({"error": e}))).into_response()
293 } else {
294 (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response()
295 }
296 }
297 }
298}
299
300/// `POST /api/v1/skill/{id}/compose`.
301///
302/// Body: `{ "budget_tokens": <N?> }`. Returns the skill body plus the
303/// reflections declared in its `composes_with_reflections` frontmatter.
304#[derive(Deserialize, Default)]
305pub struct SkillComposeBody {
306 pub budget_tokens: Option<u64>,
307}
308
309pub async fn skill_compose_route(
310 State(app): State<AppState>,
311 headers: HeaderMap,
312 Path(id): Path<String>,
313 body: Option<Json<SkillComposeBody>>,
314) -> impl IntoResponse {
315 // #949 — admin-only. Compose reads the skill body PLUS the
316 // reflections declared in `composes_with_reflections` — a
317 // multi-row read across the caller and other agents' reflection
318 // memories. Cross-tenant compose = exfiltrate the skill author's
319 // private reflection chain bundled with the executable body.
320 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "skill_compose") {
321 return resp;
322 }
323 let Json(body) = body.unwrap_or(Json(SkillComposeBody::default()));
324 let mut params = json!({"skill_id": id});
325 if let Some(b) = body.budget_tokens {
326 params[field_names::BUDGET_TOKENS] = json!(b);
327 }
328 let lock = app.db.lock().await;
329 match crate::mcp::handle_skill_compositional_context(&lock.0, ¶ms) {
330 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
331 Err(e) => {
332 if e.starts_with(crate::errors::msg::SKILL_NOT_FOUND) {
333 (StatusCode::NOT_FOUND, Json(json!({"error": e}))).into_response()
334 } else {
335 // #1261 — never forward the raw substrate error on
336 // the HTTP wire. Log the raw text; emit a generic
337 // "internal server error" to the caller.
338 tracing::error!(
339 target: SKILLS_TRACE_TARGET,
340 error = %e,
341 "skill_compose_route: substrate error (sanitized for wire response, #1261)"
342 );
343 (
344 StatusCode::INTERNAL_SERVER_ERROR,
345 Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
346 )
347 .into_response()
348 }
349 }
350 }
351}