Skip to main content

agentmux_srv/server/app_api/
dev_server.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `POST /api/v1/agent/dev_server/register` — backs the `RegisterDevServer`
5//! MCP tool. See docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md.
6//!
7//! Identity is verified the same way `ClosePane`/`UIClick`/`UIQuery` do
8//! (`ui_handlers::verified_block_id`) — the calling agent proves it IS
9//! `auth.agent_id` via its own `AGENTMUX_JEKT_KEY` signature; there is no
10//! client-supplied agent id to spoof. The container's internal dev-proxy
11//! network address is then resolved server-side from THAT agent's own
12//! container (`ContainerManager::agent_network_ip`) — never a
13//! client-supplied address, so there is nothing left to spoof there either.
14
15use axum::extract::State;
16use axum::http::StatusCode;
17use axum::response::IntoResponse;
18use axum::Json;
19use serde_json::json;
20
21use agentmux_common::api_types::{RegisterDevServerRequest, RegisterDevServerResponse};
22
23use crate::backend::container::container_name_for_slug;
24use crate::backend::dev_proxy::DEV_PROXY_PORT;
25
26use super::AppState;
27
28pub(crate) async fn handle_register_dev_server(
29    State(state): State<AppState>,
30    caller: Option<axum::Extension<crate::server::caller::Caller>>,
31    Json(req): Json<RegisterDevServerRequest>,
32) -> impl IntoResponse {
33    // Verifies `req.auth`'s signature against the claimed agent_id's own
34    // key on file. We don't need the returned block_id — the signature
35    // check succeeding is itself the proof that `req.auth.agent_id` is who
36    // it claims to be. See this module's doc comment.
37    if let Err(e) =
38        crate::server::ui_handlers::verified_block_id(&state, caller.as_deref(), &req.auth)
39    {
40        return (StatusCode::UNAUTHORIZED, Json(json!({ "error": e }))).into_response();
41    }
42    let agent_id = req.auth.agent_id.clone();
43
44    let project = req.project.trim().to_lowercase();
45    if project.is_empty() {
46        return (
47            StatusCode::BAD_REQUEST,
48            Json(json!({ "error": "project must not be empty" })),
49        )
50            .into_response();
51    }
52    // `project` becomes a DNS label in `<project>-<agent>.localhost` (see
53    // `DevProxyRegistry::routing_key`) — validate it as one instead of only
54    // checking non-empty. An unvalidated value containing e.g. '.' or
55    // whitespace would previously register successfully and hand the caller
56    // back a URL that can never actually route, discovered only when they
57    // tried to open it. reagent P2, PR #3439.
58    let is_hostname_label = !project.is_empty()
59        && project.len() <= 63
60        && !project.starts_with('-')
61        && !project.ends_with('-')
62        && project
63            .bytes()
64            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
65    if !is_hostname_label {
66        return (
67            StatusCode::BAD_REQUEST,
68            Json(json!({
69                "error": "project must be a valid hostname label: lowercase \
70                          letters, digits, and hyphens only, not starting or \
71                          ending with a hyphen, 63 characters or fewer"
72            })),
73        )
74            .into_response();
75    }
76    if req.port == 0 {
77        return (
78            StatusCode::BAD_REQUEST,
79            Json(json!({ "error": "port must be nonzero" })),
80        )
81            .into_response();
82    }
83
84    let Some(cm) = state.container_manager.get().await else {
85        return (
86            StatusCode::SERVICE_UNAVAILABLE,
87            Json(json!({
88                "error": "Docker is not available — RegisterDevServer only works from a \
89                          container-type agent's own container"
90            })),
91        )
92            .into_response();
93    };
94
95    let container_name = container_name_for_slug(&agent_id);
96    let Some(ip) = cm.agent_network_ip(&container_name).await else {
97        return (
98            StatusCode::NOT_FOUND,
99            Json(json!({
100                "error": format!(
101                    "no running container found for agent {agent_id:?} on the dev-proxy \
102                     network — RegisterDevServer only works from inside a container-type \
103                     agent's own container"
104                )
105            })),
106        )
107            .into_response();
108    };
109
110    let addr: std::net::SocketAddr = match format!("{ip}:{}", req.port).parse() {
111        Ok(a) => a,
112        Err(e) => {
113            return (
114                StatusCode::BAD_REQUEST,
115                Json(json!({ "error": format!("invalid address {ip}:{}: {e}", req.port) })),
116            )
117                .into_response();
118        }
119    };
120
121    state.dev_proxy.register(&project, &agent_id, addr).await;
122
123    // Lowercased to match the actual routing key (`DevProxyRegistry::routing_key`
124    // lowercases both halves) — this is what a browser should actually be
125    // pointed at, not necessarily `agent_id`'s on-disk casing.
126    let url = format!(
127        "http://{project}-{}.localhost:{DEV_PROXY_PORT}",
128        agent_id.to_lowercase()
129    );
130    (StatusCode::OK, Json(RegisterDevServerResponse { url })).into_response()
131}