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 invocation;
38pub mod manage;
39pub mod sessions;
40pub mod share;
41pub mod state;
42pub mod streaming;
43pub mod tasks;
44pub mod version;
45
46use std::env;
47
48use axum::middleware;
49use axum::routing::{delete, get, post};
50use axum::Router;
51
52use crate::state::AppState;
53
54/// Environment variable naming the base path the protocol is served under.
55pub const BASE_PATH_ENV: &str = "UHP_BASE_PATH";
56
57/// Where the protocol is served when the environment names nothing.
58pub const DEFAULT_BASE_PATH: &str = "/uhp";
59
60/// The base path this server serves UHP under, normalised to a leading slash
61/// and no trailing one so it can be concatenated with the protocol's own paths.
62pub fn base_path() -> String {
63 let raw = env::var(BASE_PATH_ENV).unwrap_or_else(|_| DEFAULT_BASE_PATH.to_owned());
64 let trimmed = raw.trim().trim_end_matches('/');
65 if trimmed.is_empty() {
66 String::new()
67 } else if trimmed.starts_with('/') {
68 trimmed.to_owned()
69 } else {
70 format!("/{trimmed}")
71 }
72}
73
74/// Build the UHP router, to be nested under [`base_path`].
75///
76/// Discovery sits outside the auth layer deliberately: a client must be able to
77/// learn whether this is a UHP server, and which versions it speaks, before
78/// deciding what credential to present. Everything else requires one when
79/// `EMBACLE_API_KEY` is configured.
80///
81/// Version negotiation wraps both, so even a refusal carries `UHP-Version`.
82pub fn router(state: AppState) -> Router {
83 let uhp_state = state::UhpState::new(state.shared);
84 let public_state = uhp_state.clone();
85
86 let authenticated = Router::new()
87 .route("/v1/harnesses", get(harnesses::list).post(manage::create))
88 .route(
89 "/v1/harnesses/{harness_id}",
90 get(harnesses::get)
91 .put(manage::update)
92 .delete(manage::delete),
93 )
94 .route(
95 "/v1/harnesses/{harness_id}/models",
96 get(harnesses::harness_models),
97 )
98 .route(
99 "/v1/harnesses/{harness_id}/skills/{skill_name}/files",
100 get(manage::skill_files),
101 )
102 .route("/v1/models", get(harnesses::models))
103 .route("/v1/responses", post(tasks::create))
104 .route("/v1/responses/{response_id}", get(tasks::get))
105 .route("/v1/responses/{response_id}/cancel", post(tasks::cancel))
106 .route("/v1/sessions", get(sessions::list))
107 .route(
108 "/v1/sessions/{session_id}",
109 get(sessions::get).delete(sessions::delete),
110 )
111 // The protocol names /v1/sessions/{id}; /v1/traces/{id} is the older
112 // path for the same operation, which a server MAY keep serving. Same
113 // handler, so the two cannot drift.
114 .route("/v1/traces/{session_id}", delete(sessions::delete))
115 .route("/v1/sessions/{session_id}/turns", get(sessions::turns))
116 .route(
117 "/v1/sessions/{session_id}/share",
118 get(share::get).post(share::create).delete(share::revoke),
119 )
120 .route("/v1/sessions/{session_id}/files", get(files::session_files))
121 .route(
122 "/v1/containers/{container_id}/files/{file_id}/content",
123 get(files::container_file),
124 )
125 .with_state(uhp_state)
126 .layer(middleware::from_fn(auth::require_auth));
127
128 // The shared view sits beside discovery, outside the auth layer: a share
129 // only its minter can open has not been published to anyone.
130 let public = Router::new()
131 .route("/share/{share_id}", get(share::view))
132 .with_state(public_state);
133
134 Router::new()
135 .route("/v1/uhp", get(discovery::handle))
136 .merge(public)
137 .merge(authenticated)
138 .fallback(harnesses::unknown_route)
139 .layer(middleware::from_fn(version::negotiate))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn a_base_path_is_normalised_and_can_be_emptied() {
148 // One test, not two: UHP_BASE_PATH is process-global, so two tests
149 // mutating it race each other and fail intermittently.
150 //
151 // The path is concatenated with the protocol's own "/v1/..." paths, so
152 // a trailing slash would produce "//v1/uhp" and route nowhere.
153 env::set_var(BASE_PATH_ENV, "/api/harness/");
154 assert_eq!(base_path(), "/api/harness");
155
156 env::set_var(BASE_PATH_ENV, "harness");
157 assert_eq!(base_path(), "/harness");
158
159 // Deliberate: an operator who does not run the OpenAI surface can put
160 // UHP at the root, which is what most UHP clients expect.
161 env::set_var(BASE_PATH_ENV, "/");
162 assert_eq!(base_path(), "");
163
164 env::remove_var(BASE_PATH_ENV);
165 assert_eq!(base_path(), DEFAULT_BASE_PATH);
166 }
167}