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}