Skip to main content

embacle_server/uhp/
mod.rs

1// ABOUTME: The Unified Harness Protocol surface, mounted under its own base path
2// ABOUTME: Additive — the OpenAI-compatible API at /v1 is untouched and keeps its shapes
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Unified Harness Protocol (`2026-08-11`).
8//!
9//! embacle already puts twelve CLI harnesses behind one trait, which is what
10//! UHP calls a *runner*. This module serves that catalogue over the protocol's
11//! HTTP contract.
12//!
13//! ## Why it mounts under a prefix
14//!
15//! Both surfaces define `GET /v1/models`, and the bodies are incompatible:
16//! the `OpenAI` API answers a flat `{"object":"list","data":[…]}`, UHP requires
17//! `{"backends":{"<id>":{"default":…,"models":[…]}}}`. Serving both on one path
18//! would mean content negotiation or breaking every existing caller.
19//!
20//! The protocol anticipates this — its published `servers:` block lists
21//! `http://127.0.0.1:3000/api/harness` — so UHP is designed to sit under an
22//! arbitrary base path, and the conformance suite takes a `--base-url`. The
23//! `OpenAI` surface keeps `/v1` exactly as it was.
24//!
25//! ## What is implemented
26//!
27//! Discovery, harnesses and the model catalogue, with version negotiation and
28//! the structured error envelope beneath them. Tasks, streaming, sessions and
29//! files are not yet served; [`discovery::Capabilities::current`] reports that
30//! honestly rather than advertising surfaces that would fail on first use.
31
32pub mod auth;
33pub mod discovery;
34pub mod error;
35pub mod files;
36pub mod harnesses;
37pub mod manage;
38pub mod sessions;
39pub mod share;
40pub mod state;
41pub mod streaming;
42pub mod tasks;
43pub mod version;
44
45use std::env;
46
47use axum::middleware;
48use axum::routing::{delete, get, post};
49use axum::Router;
50
51use crate::state::AppState;
52
53/// Environment variable naming the base path the protocol is served under.
54pub const BASE_PATH_ENV: &str = "UHP_BASE_PATH";
55
56/// Where the protocol is served when the environment names nothing.
57pub const DEFAULT_BASE_PATH: &str = "/uhp";
58
59/// The base path this server serves UHP under, normalised to a leading slash
60/// and no trailing one so it can be concatenated with the protocol's own paths.
61pub fn base_path() -> String {
62    let raw = env::var(BASE_PATH_ENV).unwrap_or_else(|_| DEFAULT_BASE_PATH.to_owned());
63    let trimmed = raw.trim().trim_end_matches('/');
64    if trimmed.is_empty() {
65        String::new()
66    } else if trimmed.starts_with('/') {
67        trimmed.to_owned()
68    } else {
69        format!("/{trimmed}")
70    }
71}
72
73/// Build the UHP router, to be nested under [`base_path`].
74///
75/// Discovery sits outside the auth layer deliberately: a client must be able to
76/// learn whether this is a UHP server, and which versions it speaks, before
77/// deciding what credential to present. Everything else requires one when
78/// `EMBACLE_API_KEY` is configured.
79///
80/// Version negotiation wraps both, so even a refusal carries `UHP-Version`.
81pub fn router(state: AppState) -> Router {
82    let uhp_state = state::UhpState::new(state.shared);
83    let public_state = uhp_state.clone();
84
85    let authenticated = Router::new()
86        .route("/v1/harnesses", get(harnesses::list).post(manage::create))
87        .route(
88            "/v1/harnesses/{harness_id}",
89            get(harnesses::get)
90                .put(manage::update)
91                .delete(manage::delete),
92        )
93        .route(
94            "/v1/harnesses/{harness_id}/models",
95            get(harnesses::harness_models),
96        )
97        .route(
98            "/v1/harnesses/{harness_id}/skills/{skill_name}/files",
99            get(manage::skill_files),
100        )
101        .route("/v1/models", get(harnesses::models))
102        .route("/v1/responses", post(tasks::create))
103        .route("/v1/responses/{response_id}", get(tasks::get))
104        .route("/v1/responses/{response_id}/cancel", post(tasks::cancel))
105        .route("/v1/sessions", get(sessions::list))
106        .route(
107            "/v1/sessions/{session_id}",
108            get(sessions::get).delete(sessions::delete),
109        )
110        // The protocol names /v1/sessions/{id}; /v1/traces/{id} is the older
111        // path for the same operation, which a server MAY keep serving. Same
112        // handler, so the two cannot drift.
113        .route("/v1/traces/{session_id}", delete(sessions::delete))
114        .route("/v1/sessions/{session_id}/turns", get(sessions::turns))
115        .route(
116            "/v1/sessions/{session_id}/share",
117            get(share::get).post(share::create).delete(share::revoke),
118        )
119        .route("/v1/sessions/{session_id}/files", get(files::session_files))
120        .route(
121            "/v1/containers/{container_id}/files/{file_id}/content",
122            get(files::container_file),
123        )
124        .with_state(uhp_state)
125        .layer(middleware::from_fn(auth::require_auth));
126
127    // The shared view sits beside discovery, outside the auth layer: a share
128    // only its minter can open has not been published to anyone.
129    let public = Router::new()
130        .route("/share/{share_id}", get(share::view))
131        .with_state(public_state);
132
133    Router::new()
134        .route("/v1/uhp", get(discovery::handle))
135        .merge(public)
136        .merge(authenticated)
137        .fallback(harnesses::unknown_route)
138        .layer(middleware::from_fn(version::negotiate))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn a_base_path_is_normalised_and_can_be_emptied() {
147        // One test, not two: UHP_BASE_PATH is process-global, so two tests
148        // mutating it race each other and fail intermittently.
149        //
150        // The path is concatenated with the protocol's own "/v1/..." paths, so
151        // a trailing slash would produce "//v1/uhp" and route nowhere.
152        env::set_var(BASE_PATH_ENV, "/api/harness/");
153        assert_eq!(base_path(), "/api/harness");
154
155        env::set_var(BASE_PATH_ENV, "harness");
156        assert_eq!(base_path(), "/harness");
157
158        // Deliberate: an operator who does not run the OpenAI surface can put
159        // UHP at the root, which is what most UHP clients expect.
160        env::set_var(BASE_PATH_ENV, "/");
161        assert_eq!(base_path(), "");
162
163        env::remove_var(BASE_PATH_ENV);
164        assert_eq!(base_path(), DEFAULT_BASE_PATH);
165    }
166}