Skip to main content

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, &params) {
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, &params) {
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, &params) {
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, &params, 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, &params, 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, &params) {
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}