Skip to main content

embacle_server/uhp/
harnesses.rs

1// ABOUTME: GET /v1/harnesses, /v1/harnesses/{id} and the UHP model catalogue endpoints
2// ABOUTME: Each installed embacle runner is advertised as one harness; availability is probed
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Harnesses and their models.
8//!
9//! embacle already runs twelve CLI harnesses behind one trait, which is exactly
10//! what UHP calls a **runner**: a server that puts existing harnesses behind the
11//! contract and advertises them as its catalog. So a harness here is one
12//! embacle provider whose binary is actually installed.
13//!
14//! `available` is computed, never asserted. The specification is blunt about
15//! why: listing a model as available and then failing the task is the worst
16//! outcome for a client, because a user has already chosen it.
17
18use std::collections::BTreeMap;
19use std::env;
20use std::path::{Component, Path as FsPath};
21
22use axum::extract::{Path, State};
23use axum::response::IntoResponse;
24use axum::Json;
25use base64::engine::general_purpose::STANDARD;
26use base64::{DecodeError, Engine};
27use embacle::config::CliRunnerType;
28use embacle::discovery::resolve_binary;
29use serde::{Deserialize, Serialize};
30
31use super::error::UhpFailure;
32use super::state::UhpState;
33use crate::runner::ALL_PROVIDERS;
34use crate::state::SharedState;
35
36/// One harness this server can run.
37#[derive(Debug, Clone, Serialize)]
38pub struct Harness {
39    /// Stable id, prefixed `chrn_` as the schema requires.
40    pub id: String,
41    /// Always `harness`.
42    pub object: &'static str,
43    /// Human-readable name.
44    pub name: String,
45    /// Opaque harness family, e.g. `claude-code`. Deliberately not enumerated
46    /// by the specification, so a client must treat it as a string.
47    pub base: String,
48    /// The model used when a task names none.
49    #[serde(rename = "defaultModel", skip_serializing_if = "Option::is_none")]
50    pub default_model: Option<String>,
51    /// Skill bundles this harness carries.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub skills: Vec<Skill>,
54    /// MCP servers this harness may reach.
55    #[serde(rename = "mcpServers", default, skip_serializing_if = "Vec::is_empty")]
56    pub mcp_servers: Vec<McpServer>,
57    /// Tools withheld from the model on this harness.
58    #[serde(
59        rename = "disabledTools",
60        default,
61        skip_serializing_if = "Vec::is_empty"
62    )]
63    pub disabled_tools: Vec<String>,
64}
65
66/// One file inside a skill bundle.
67///
68/// A member may carry its bytes as `content` or, when it is not text, as
69/// `content_b64`. Both round-trip, and anything else the client attached comes
70/// back too: a bundle is the client's, and dropping a field it set would be
71/// indistinguishable from the server having stored it.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SkillFile {
74    /// Path relative to the bundle root.
75    pub path: String,
76    /// Text contents.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub content: Option<String>,
79    /// Base64 contents, for a member that is not text.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub content_b64: Option<String>,
82    /// Everything else the client attached.
83    #[serde(flatten)]
84    pub rest: serde_json::Map<String, serde_json::Value>,
85}
86
87impl SkillFile {
88    /// Where this member sits inside its bundle, when `path` is a plain
89    /// relative path.
90    ///
91    /// The path is the client's and is written to disk when a task runs, so
92    /// anything that could climb out of the bundle — a `..`, a root, a drive
93    /// prefix, a leading `.` — is refused rather than resolved.
94    #[must_use]
95    pub fn relative_path(&self) -> Option<&FsPath> {
96        let path = FsPath::new(&self.path);
97        let mut components = path.components().peekable();
98        components.peek()?;
99        components
100            .all(|c| matches!(c, Component::Normal(_)))
101            .then_some(path)
102    }
103
104    /// The member's bytes: its text, else its decoded base64, else none at all.
105    ///
106    /// # Errors
107    ///
108    /// Returns the decode error when `content_b64` is not standard base64.
109    pub fn bytes(&self) -> Result<Vec<u8>, DecodeError> {
110        match (&self.content, &self.content_b64) {
111            (Some(text), _) => Ok(text.as_bytes().to_vec()),
112            (None, Some(encoded)) => STANDARD.decode(encoded),
113            (None, None) => Ok(Vec::new()),
114        }
115    }
116}
117
118/// A skill bundle: a manifest plus whatever it references.
119///
120/// The whole folder round-trips. Storing only `SKILL.md` breaks every skill
121/// that carries references, scripts or data — and breaks it at run time, long
122/// after the client thought it had configured the harness.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Skill {
125    /// Bundle name, which is how it is addressed.
126    pub name: String,
127    /// Whether the harness may use it.
128    #[serde(default)]
129    pub enabled: bool,
130    /// Every file in the bundle.
131    #[serde(default)]
132    pub files: Vec<SkillFile>,
133}
134
135impl Skill {
136    /// The manifest every bundle must carry.
137    pub const MANIFEST: &'static str = "SKILL.md";
138
139    /// Whether this bundle carries its manifest.
140    ///
141    /// A bundle without one would be stored and then silently ignored at run
142    /// time, which is the hardest kind of failure for a user to diagnose — so
143    /// it is refused at config time instead.
144    #[must_use]
145    pub fn has_manifest(&self) -> bool {
146        self.files.iter().any(|f| f.path == Self::MANIFEST)
147    }
148
149    /// Whether the bundle's name is one plain path segment.
150    ///
151    /// The name becomes the folder the bundle is written to when a task runs,
152    /// so a name carrying a separator or a `..` would place it elsewhere.
153    #[must_use]
154    pub fn has_plain_name(&self) -> bool {
155        let mut components = FsPath::new(&self.name).components();
156        matches!(
157            (components.next(), components.next()),
158            (Some(Component::Normal(_)), None)
159        )
160    }
161}
162
163/// An MCP server a harness may reach.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct McpServer {
166    /// Server name.
167    pub name: String,
168    /// Where it lives.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub url: Option<String>,
171    /// How to reach it.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub transport: Option<String>,
174    /// Whether it is contacted at all.
175    ///
176    /// Preserved exactly as sent: the difference between true and false decides
177    /// whether a third party is contacted, so a client must be able to tell a
178    /// disabled entry from an enabled one.
179    #[serde(default)]
180    pub enabled: bool,
181}
182
183/// The `GET /v1/harnesses` body.
184#[derive(Debug, Clone, Serialize)]
185pub struct HarnessList {
186    /// Every harness whose binary is installed.
187    pub harnesses: Vec<Harness>,
188}
189
190/// One model, with computed availability.
191#[derive(Debug, Clone, Serialize)]
192pub struct Model {
193    /// Model id as the harness names it.
194    pub id: String,
195    /// Which harness serves it.
196    pub backend: String,
197    /// Whether this server can serve it right now.
198    pub available: bool,
199    /// Whether it is the harness's default.
200    pub default: bool,
201}
202
203/// Models grouped by backend, which is the shape the schema requires — a flat
204/// list is what the `OpenAI` surface serves, and the two must not be confused.
205#[derive(Debug, Clone, Serialize)]
206pub struct BackendModels {
207    /// The model used when a task names none.
208    pub default: String,
209    /// Every model this backend accepts.
210    pub models: Vec<Model>,
211}
212
213/// The `GET /v1/harnesses/{id}/models` body.
214///
215/// Deliberately NOT [`ModelCatalog`]: one harness answers a flat `models`
216/// array plus its own defaults, while the server-wide catalogue groups by
217/// backend. Serving the grouped shape here fails schema validation on a
218/// missing `models` property.
219#[derive(Debug, Clone, Serialize)]
220pub struct HarnessModels {
221    /// The harness these models belong to.
222    pub harness_id: String,
223    /// Its backend family.
224    pub backend: String,
225    /// The model used when a task names none.
226    pub default: String,
227    /// Every model this harness accepts.
228    pub models: Vec<Model>,
229}
230
231/// The `GET /v1/models` body.
232#[derive(Debug, Clone, Serialize)]
233pub struct ModelCatalog {
234    /// Keyed by backend id.
235    pub backends: BTreeMap<String, BackendModels>,
236}
237
238/// A harness id for a provider name, satisfying the schema's `^chrn_` pattern.
239fn harness_id(provider: &str) -> String {
240    let slug: String = provider
241        .chars()
242        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
243        .collect();
244    format!("chrn_{slug}")
245}
246
247/// Whether this provider's CLI binary resolves on this machine.
248fn installed(provider: CliRunnerType) -> bool {
249    let env_override = env::var(provider.env_override_key()).ok();
250    resolve_binary(provider.binary_name(), env_override.as_deref()).is_ok()
251}
252
253/// One installed provider, as everything the routes need to describe or run it.
254pub struct Discovered {
255    /// The embacle runner type, for constructing the runner.
256    pub provider: CliRunnerType,
257    /// Its provider name, which is also its UHP backend id.
258    pub name: String,
259    /// The harness object served over the wire.
260    pub harness: Harness,
261    /// The models it accepts.
262    pub models: Vec<String>,
263}
264
265/// Every installed provider, as harnesses.
266pub async fn discover(state: &SharedState) -> Vec<Discovered> {
267    let mut found = Vec::new();
268    for &provider in ALL_PROVIDERS.iter() {
269        if !installed(provider) {
270            continue;
271        }
272        let Ok(runner) = state.get_runner(provider).await else {
273            continue;
274        };
275        let name = runner.name().to_owned();
276        let models = runner.available_models().to_vec();
277        let default_model = runner.default_model().to_owned();
278        found.push(Discovered {
279            provider,
280            harness: Harness {
281                id: harness_id(&name),
282                object: "harness",
283                name: name.clone(),
284                base: name.clone(),
285                default_model: Some(default_model),
286                skills: Vec::new(),
287                mcp_servers: Vec::new(),
288                disabled_tools: Vec::new(),
289            },
290            name,
291            models,
292        });
293    }
294    found
295}
296
297/// The harness `id` names, with the installed base that runs it.
298///
299/// A configured harness is looked up first and runs on the discovered harness
300/// whose base it names, carrying its own id, skills, MCP servers and disabled
301/// tools; its default model falls back to that base's when it names none.
302/// Anything else is looked up among the discovered harnesses. `None` when
303/// the id names neither, or names a configured harness whose base is no longer
304/// installed.
305pub async fn resolve(state: &UhpState, id: &str) -> Option<Discovered> {
306    let found = discover(&state.shared).await;
307    let Some(configured) = state.configured.get(id) else {
308        return found.into_iter().find(|d| d.harness.id == id);
309    };
310    let base = found
311        .into_iter()
312        .find(|d| d.harness.base == configured.base)?;
313    Some(Discovered {
314        provider: base.provider,
315        name: base.name,
316        harness: Harness {
317            default_model: configured
318                .default_model
319                .clone()
320                .or(base.harness.default_model),
321            ..configured
322        },
323        models: base.models,
324    })
325}
326
327/// Handle `GET /v1/harnesses`.
328///
329/// Every discovered harness, then every configured one: a harness a client
330/// created is as runnable as one discovery found, so it is listed beside them.
331pub async fn list(State(state): State<UhpState>) -> impl IntoResponse {
332    let mut harnesses: Vec<Harness> = discover(&state.shared)
333        .await
334        .into_iter()
335        .map(|d| d.harness)
336        .collect();
337    harnesses.extend(state.configured.all());
338    Json(HarnessList { harnesses })
339}
340
341/// Handle `GET /v1/harnesses/{harness_id}`.
342///
343/// # Errors
344///
345/// Answers `404 harness_not_found` when the id names nothing installed.
346pub async fn get(
347    State(state): State<UhpState>,
348    Path(id): Path<String>,
349) -> Result<Json<Harness>, UhpFailure> {
350    if let Some(configured) = state.configured.get(&id) {
351        return Ok(Json(configured));
352    }
353    discover(&state.shared)
354        .await
355        .into_iter()
356        .find(|d| d.harness.id == id)
357        .map(|d| Json(d.harness))
358        .ok_or_else(|| {
359            UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
360        })
361}
362
363/// Build the catalogue for a set of discovered harnesses.
364fn catalogue(found: Vec<Discovered>) -> ModelCatalog {
365    let mut backends = BTreeMap::new();
366    for d in found {
367        let name = d.name;
368        let default = d.harness.default_model.unwrap_or_default();
369        let models = d
370            .models
371            .into_iter()
372            .map(|id| Model {
373                default: id == default,
374                id,
375                backend: name.clone(),
376                // Installed and constructible, which is what this server can
377                // actually act on. A model the harness lists but cannot serve
378                // would be the failure the spec warns about.
379                available: true,
380            })
381            .collect();
382        backends.insert(name, BackendModels { default, models });
383    }
384    ModelCatalog { backends }
385}
386
387/// Handle `GET /v1/models` on the UHP surface.
388pub async fn models(State(state): State<UhpState>) -> impl IntoResponse {
389    Json(catalogue(discover(&state.shared).await))
390}
391
392/// Handle `GET /v1/harnesses/{harness_id}/models`.
393///
394/// A configured harness answers with its base's models.
395///
396/// # Errors
397///
398/// Answers `404 harness_not_found` when the id names nothing installed.
399pub async fn harness_models(
400    State(state): State<UhpState>,
401    Path(id): Path<String>,
402) -> Result<Json<HarnessModels>, UhpFailure> {
403    let d = resolve(&state, &id).await.ok_or_else(|| {
404        UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
405    })?;
406
407    let name = d.name;
408    let default = d.harness.default_model.unwrap_or_default();
409    Ok(Json(HarnessModels {
410        harness_id: d.harness.id,
411        backend: name.clone(),
412        models: d
413            .models
414            .into_iter()
415            .map(|id| Model {
416                default: id == default,
417                id,
418                backend: name.clone(),
419                available: true,
420            })
421            .collect(),
422        default,
423    }))
424}
425
426/// Fallback for a path under the UHP base that names no route.
427pub async fn unknown_route() -> UhpFailure {
428    UhpFailure::not_found("not_found", "no such endpoint on this server")
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn a_harness_id_satisfies_the_schema_pattern() {
437        // The Harness schema pins `^chrn_`, so an id that does not start with
438        // it fails validation for every harness the server advertises.
439        assert!(harness_id("claude_code").starts_with("chrn_"));
440        assert_eq!(harness_id("claude-code"), "chrn_claude_code");
441        assert_eq!(harness_id("copilot"), "chrn_copilot");
442    }
443
444    #[test]
445    fn the_catalogue_groups_by_backend_and_marks_one_default() {
446        let found = vec![Discovered {
447            provider: CliRunnerType::Copilot,
448            name: "copilot".to_owned(),
449            harness: Harness {
450                id: harness_id("copilot"),
451                object: "harness",
452                name: "copilot".to_owned(),
453                base: "copilot".to_owned(),
454                default_model: Some("gpt-5".to_owned()),
455                skills: Vec::new(),
456                mcp_servers: Vec::new(),
457                disabled_tools: Vec::new(),
458            },
459            models: vec!["gpt-5".to_owned(), "claude-sonnet".to_owned()],
460        }];
461        let cat = catalogue(found);
462
463        let backend = cat.backends.get("copilot").expect("backend present"); // Safe: test assertion
464        assert_eq!(backend.default, "gpt-5");
465        assert_eq!(backend.models.len(), 2);
466        assert_eq!(
467            backend.models.iter().filter(|m| m.default).count(),
468            1,
469            "exactly one model is the default"
470        );
471        assert!(
472            backend.models.iter().all(|m| m.available),
473            "availability is a boolean on every model, never absent"
474        );
475    }
476}