Skip to main content

embacle_server/uhp/
manage.rs

1// ABOUTME: Harness management — create, update and delete configured harnesses over the API
2// ABOUTME: A base this server cannot run is refused at config time, not at task time
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Configured harnesses.
8//!
9//! The catalogue this server advertises is discovered: one harness per embacle
10//! runner whose binary is installed. Management adds a second kind — a *named
11//! configuration* over one of those bases, so a client can keep "the reviewer"
12//! and "the summariser" as distinct harnesses that happen to run on the same
13//! CLI.
14//!
15//! A base this server cannot run is refused when the harness is **created**,
16//! not when a task is finally sent to it. The specification is explicit about
17//! why: a server that accepts a base it cannot run fails at task time instead,
18//! after the client has committed.
19
20use std::collections::HashMap;
21use std::sync::RwLock;
22
23use axum::extract::{Path, State};
24use axum::http::StatusCode;
25use axum::Json;
26use serde::Deserialize;
27use serde_json::json;
28use uuid::Uuid;
29
30use super::error::{ErrorType, UhpFailure};
31use super::harnesses::{Harness, McpServer, Skill};
32use super::state::UhpState;
33
34/// What a client sends to create or update a harness.
35#[derive(Debug, Clone, Deserialize)]
36pub struct HarnessConfig {
37    /// Human-readable name.
38    pub name: String,
39    /// Which installed harness family it runs on.
40    pub base: String,
41    /// The model to use when a task names none.
42    #[serde(default, alias = "defaultModel")]
43    pub default_model: Option<String>,
44    /// Skill bundles to attach.
45    #[serde(default)]
46    pub skills: Vec<Skill>,
47    /// MCP servers this harness may reach.
48    #[serde(default, alias = "mcpServers")]
49    pub mcp_servers: Vec<McpServer>,
50    /// Tools withheld from the model.
51    #[serde(default, alias = "disabledTools")]
52    pub disabled_tools: Vec<String>,
53}
54
55/// Refuse a skill bundle that carries no manifest, or that cannot be written
56/// out for a task as it stands.
57///
58/// A bundle is written to disk under its name when a task runs on the harness,
59/// so a name or member path that would land outside it, or a member whose
60/// base64 does not decode, is refused here rather than at every task.
61fn validated_skills(skills: &[Skill]) -> Result<(), UhpFailure> {
62    let refuse =
63        |message: String| UhpFailure::new(ErrorType::InvalidRequestError, "invalid_input", message);
64    for skill in skills {
65        if !skill.has_manifest() {
66            return Err(refuse(format!(
67                "skill '{}' carries no {} — it would be stored and then silently ignored at run \
68                 time",
69                skill.name,
70                Skill::MANIFEST
71            )));
72        }
73        if !skill.has_plain_name() {
74            return Err(refuse(format!(
75                "skill '{}' must be named by one path segment",
76                skill.name
77            )));
78        }
79        for file in &skill.files {
80            if file.relative_path().is_none() {
81                return Err(refuse(format!(
82                    "skill '{}' member '{}' is not a relative path inside the bundle",
83                    skill.name, file.path
84                )));
85            }
86            if file.bytes().is_err() {
87                return Err(refuse(format!(
88                    "skill '{}' member '{}' carries content_b64 that is not base64",
89                    skill.name, file.path
90                )));
91            }
92        }
93    }
94    Ok(())
95}
96
97/// Harnesses a client configured, as opposed to the ones discovery found.
98///
99/// LIMITATION(registre#410): `ConfiguredHarnesses` is per-process, so a harness created
100/// on one instance is invisible to another.
101#[derive(Debug, Default)]
102pub struct ConfiguredHarnesses(RwLock<HashMap<String, Harness>>);
103
104impl ConfiguredHarnesses {
105    /// Every configured harness.
106    pub fn all(&self) -> Vec<Harness> {
107        self.0
108            .read()
109            .map(|h| h.values().cloned().collect())
110            .unwrap_or_default()
111    }
112
113    /// One configured harness by id.
114    pub fn get(&self, id: &str) -> Option<Harness> {
115        self.0.read().ok()?.get(id).cloned()
116    }
117
118    /// Store or replace one.
119    pub fn put(&self, harness: Harness) {
120        if let Ok(mut all) = self.0.write() {
121            all.insert(harness.id.clone(), harness);
122        }
123    }
124
125    /// Remove one, reporting whether it was there.
126    pub fn remove(&self, id: &str) -> bool {
127        self.0.write().is_ok_and(|mut all| all.remove(id).is_some())
128    }
129}
130
131/// Refuse a base this server has no installed harness for.
132async fn validated_base(state: &UhpState, base: &str) -> Result<(), UhpFailure> {
133    let supported: Vec<String> = super::harnesses::discover(&state.shared)
134        .await
135        .into_iter()
136        .map(|d| d.harness.base)
137        .collect();
138
139    if supported.iter().any(|b| b == base) {
140        return Ok(());
141    }
142    Err(UhpFailure::new(
143        ErrorType::InvalidRequestError,
144        "unsupported_base",
145        format!("this server has no installed harness for base '{base}'"),
146    )
147    // The supported list travels with the refusal so a client can correct
148    // itself without a second round trip to discover what it may ask for.
149    .with_detail(json!({ "supported": supported })))
150}
151
152/// Handle `POST /v1/harnesses`.
153///
154/// # Errors
155///
156/// Answers `400 unsupported_base` when no installed harness runs that base.
157pub async fn create(
158    State(state): State<UhpState>,
159    Json(config): Json<HarnessConfig>,
160) -> Result<Json<Harness>, UhpFailure> {
161    validated_base(&state, &config.base).await?;
162    validated_skills(&config.skills)?;
163
164    let harness = Harness {
165        id: format!("chrn_{}", Uuid::new_v4().simple()),
166        object: "harness",
167        name: config.name,
168        base: config.base,
169        default_model: config.default_model,
170        skills: config.skills,
171        mcp_servers: config.mcp_servers,
172        disabled_tools: config.disabled_tools,
173    };
174    state.configured.put(harness.clone());
175    Ok(Json(harness))
176}
177
178/// Handle `PUT /v1/harnesses/{harness_id}`.
179///
180/// The base is immutable: changing it would silently repoint every session
181/// already running on this harness at a different CLI.
182///
183/// # Errors
184///
185/// Answers `404 harness_not_found` for an unknown id and `400 unsupported_base`
186/// when the body names a base this server cannot run.
187pub async fn update(
188    State(state): State<UhpState>,
189    Path(id): Path<String>,
190    Json(config): Json<HarnessConfig>,
191) -> Result<Json<Harness>, UhpFailure> {
192    let existing = state.configured.get(&id).ok_or_else(|| {
193        UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
194    })?;
195    validated_base(&state, &config.base).await?;
196    validated_skills(&config.skills)?;
197
198    // An edit that names no skills keeps the ones already there. A client that
199    // PUTs back a rename must not lose the bundle it never mentioned.
200    let updated = Harness {
201        id: existing.id,
202        object: "harness",
203        name: config.name,
204        base: existing.base,
205        default_model: config.default_model.or(existing.default_model),
206        skills: if config.skills.is_empty() {
207            existing.skills
208        } else {
209            config.skills
210        },
211        mcp_servers: if config.mcp_servers.is_empty() {
212            existing.mcp_servers
213        } else {
214            config.mcp_servers
215        },
216        disabled_tools: if config.disabled_tools.is_empty() {
217            existing.disabled_tools
218        } else {
219            config.disabled_tools
220        },
221    };
222    state.configured.put(updated.clone());
223    Ok(Json(updated))
224}
225
226/// Handle `DELETE /v1/harnesses/{harness_id}`.
227///
228/// # Errors
229///
230/// Answers `404 harness_not_found` when no configured harness has that id.
231pub async fn delete(
232    State(state): State<UhpState>,
233    Path(id): Path<String>,
234) -> Result<StatusCode, UhpFailure> {
235    if state.configured.remove(&id) {
236        Ok(StatusCode::NO_CONTENT)
237    } else {
238        Err(UhpFailure::not_found(
239            "harness_not_found",
240            "no harness with that id is configured",
241        ))
242    }
243}
244
245/// The files of one skill bundle.
246#[derive(Debug, Clone, serde::Serialize)]
247pub struct SkillFiles {
248    /// Every file in the bundle, as stored.
249    pub files: Vec<super::harnesses::SkillFile>,
250}
251
252/// Handle `GET /v1/harnesses/{harness_id}/skills/{skill_name}/files`.
253///
254/// The whole folder comes back, not just the manifest.
255///
256/// # Errors
257///
258/// Answers `404 harness_not_found` for an unknown harness and
259/// `404 skill_not_found` when it carries no bundle by that name.
260pub async fn skill_files(
261    State(state): State<UhpState>,
262    Path((harness_id, skill_name)): Path<(String, String)>,
263) -> Result<Json<SkillFiles>, UhpFailure> {
264    let harness = state.configured.get(&harness_id).ok_or_else(|| {
265        UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
266    })?;
267    let skill = harness
268        .skills
269        .into_iter()
270        .find(|s| s.name == skill_name)
271        .ok_or_else(|| {
272            UhpFailure::not_found(
273                "skill_not_found",
274                "this harness carries no skill by that name",
275            )
276        })?;
277    Ok(Json(SkillFiles { files: skill.files }))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn harness(id: &str, base: &str) -> Harness {
285        Harness {
286            id: id.to_owned(),
287            object: "harness",
288            name: "n".to_owned(),
289            base: base.to_owned(),
290            default_model: None,
291            skills: Vec::new(),
292            mcp_servers: Vec::new(),
293            disabled_tools: Vec::new(),
294        }
295    }
296
297    #[test]
298    fn a_binary_member_round_trips_by_its_base64() {
299        use super::super::harnesses::SkillFile;
300
301        // The conformance bundle carries assets/blob.bin as content_b64, not
302        // content. A type that models only text drops it silently, and the
303        // folder comes back missing a member the client set.
304        let file = SkillFile {
305            path: "assets/blob.bin".to_owned(),
306            content: None,
307            content_b64: Some("AAECAwQF".to_owned()),
308            rest: serde_json::Map::new(),
309        };
310        let json = serde_json::to_string(&file).expect("serialises"); // Safe: test assertion
311        assert!(
312            json.contains("AAECAwQF"),
313            "the binary member's bytes must survive the round trip: {json}"
314        );
315        assert!(
316            !json.contains("\"content\":"),
317            "a member with no text content must not gain an empty one"
318        );
319    }
320
321    #[test]
322    fn a_skill_bundle_without_a_manifest_is_refused() {
323        use super::super::harnesses::SkillFile;
324
325        let ok = Skill {
326            name: "good".to_owned(),
327            enabled: true,
328            files: vec![SkillFile {
329                path: "SKILL.md".to_owned(),
330                content: Some("# skill".to_owned()),
331                content_b64: None,
332                rest: serde_json::Map::new(),
333            }],
334        };
335        assert!(validated_skills(&[ok]).is_ok());
336
337        let bad = Skill {
338            name: "no-manifest".to_owned(),
339            enabled: true,
340            files: vec![SkillFile {
341                path: "notes.md".to_owned(),
342                content: Some("no manifest here".to_owned()),
343                content_b64: None,
344                rest: serde_json::Map::new(),
345            }],
346        };
347        assert!(
348            validated_skills(&[bad]).is_err(),
349            "a bundle with no SKILL.md must be refused at config time, not ignored at run time"
350        );
351    }
352
353    #[test]
354    fn a_configured_harness_round_trips_and_then_is_gone() {
355        let store = ConfiguredHarnesses::default();
356        store.put(harness("chrn_a", "claude-code"));
357
358        assert_eq!(
359            store.get("chrn_a").map(|h| h.base),
360            Some("claude-code".to_owned())
361        );
362        assert_eq!(store.all().len(), 1);
363
364        assert!(
365            store.remove("chrn_a"),
366            "removing a present harness reports true"
367        );
368        assert!(
369            store.get("chrn_a").is_none(),
370            "a deleted harness must not still resolve"
371        );
372        assert!(
373            !store.remove("chrn_a"),
374            "removing it twice reports false, so the caller can answer 404"
375        );
376    }
377
378    #[test]
379    fn a_created_id_satisfies_the_schema_pattern() {
380        let id = format!("chrn_{}", Uuid::new_v4().simple());
381        assert!(id.starts_with("chrn_"));
382        assert!(id.len() > "chrn_".len());
383    }
384}