1use 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#[derive(Debug, Clone, Deserialize)]
36pub struct HarnessConfig {
37 pub name: String,
39 pub base: String,
41 #[serde(default, alias = "defaultModel")]
43 pub default_model: Option<String>,
44 #[serde(default)]
46 pub skills: Vec<Skill>,
47 #[serde(default, alias = "mcpServers")]
49 pub mcp_servers: Vec<McpServer>,
50 #[serde(default, alias = "disabledTools")]
52 pub disabled_tools: Vec<String>,
53}
54
55fn 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#[derive(Debug, Default)]
102pub struct ConfiguredHarnesses(RwLock<HashMap<String, Harness>>);
103
104impl ConfiguredHarnesses {
105 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 pub fn get(&self, id: &str) -> Option<Harness> {
115 self.0.read().ok()?.get(id).cloned()
116 }
117
118 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 pub fn remove(&self, id: &str) -> bool {
127 self.0.write().is_ok_and(|mut all| all.remove(id).is_some())
128 }
129}
130
131async 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 .with_detail(json!({ "supported": supported })))
150}
151
152pub 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
178pub 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 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
226pub 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#[derive(Debug, Clone, serde::Serialize)]
247pub struct SkillFiles {
248 pub files: Vec<super::harnesses::SkillFile>,
250}
251
252pub 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 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"); 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}