Skip to main content

agent_first_http/cli/
connect.rs

1//! The host every client command talks to, resolved once per run.
2//!
3//! Two things used to be the caller's job on every single invocation: saying
4//! where the host is, and carrying its token there. Both are now defaults with
5//! an override. When `--endpoint-url` (or `AFHTTP_ENDPOINT_URL`) is absent, the
6//! standard local `afhttp-host` container is discovered and its own token read
7//! from it, so the ordinary local case is just the command. When an endpoint is
8//! named, nothing is discovered — a caller who says where to go decides how to
9//! authenticate too, by naming a source for the token.
10//!
11//! Discovery is read-only. It never starts, recreates, or reconfigures a
12//! container; `afhttp container install` remains the only thing that does.
13
14use agent_first_data::ValueSource;
15use agent_first_data::value_source::SecretString;
16
17use crate::cli::token_source;
18use crate::sdk::Client;
19use crate::shared::error::Error;
20
21/// A host named on argv, or the local one to go find.
22#[derive(Debug, Clone, Default)]
23pub struct Connection {
24    /// `--endpoint-url`, else `AFHTTP_ENDPOINT_URL`, else discovery.
25    pub endpoint: Option<String>,
26    /// `--token-secret`, else `AFHTTP_TOKEN_SECRET`, else whatever the
27    /// discovered host says its token is.
28    pub token: Option<ValueSource>,
29}
30
31/// The same connection with every source read.
32#[derive(Debug, Clone)]
33pub struct Resolved {
34    pub endpoint: String,
35    pub token: Option<SecretString>,
36}
37
38impl Connection {
39    pub fn new(endpoint: Option<String>, token: Option<ValueSource>) -> Self {
40        Self { endpoint, token }
41    }
42
43    /// Whether the caller named the host, as opposed to it being discovered.
44    /// A recommended follow-up command repeats the first and omits the second,
45    /// so the next run rediscovers rather than pinning a container's address.
46    pub fn is_explicit(&self) -> bool {
47        self.endpoint.is_some()
48    }
49
50    /// Read the token source, then find the host if none was named.
51    ///
52    /// The token is resolved first so an explicit `--token-secret` still wins
53    /// for a discovered host: a caller may know a token the container's own
54    /// data volume does not hold.
55    pub async fn resolve(&self) -> Result<Resolved, Error> {
56        let token = self.token.as_ref().map(token_source::read).transpose()?;
57        match self.endpoint.as_deref() {
58            Some(endpoint) => Ok(Resolved {
59                endpoint: endpoint.to_string(),
60                token,
61            }),
62            None => {
63                let host = crate::cli::cmd::container::discover_default_local_host(
64                    token.as_ref().map(SecretString::expose_secret),
65                )
66                .await?;
67                Ok(Resolved {
68                    endpoint: host.endpoint,
69                    token: host.token_secret,
70                })
71            }
72        }
73    }
74
75    /// Resolve and connect in one step, for the commands that need nothing from
76    /// the connection but a client.
77    pub async fn client(&self) -> Result<Client, Error> {
78        self.resolve().await?.client()
79    }
80}
81
82impl Resolved {
83    pub fn client(&self) -> Result<Client, Error> {
84        let client = Client::connect(&self.endpoint)?;
85        Ok(match &self.token {
86            Some(token) => client.with_token(token.expose_secret()),
87            None => client,
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[tokio::test]
97    async fn a_named_endpoint_resolves_without_discovery() {
98        // No container runtime is consulted: the endpoint and the token are
99        // both on argv, so this test passes on a machine with no Docker at all.
100        let connection = Connection::new(
101            Some("ws://127.0.0.1:9222".to_string()),
102            Some(ValueSource::Literal("t0ken".to_string())),
103        );
104        assert!(connection.is_explicit());
105        let resolved = connection.resolve().await.expect("explicit host resolves");
106        assert_eq!(resolved.endpoint, "ws://127.0.0.1:9222");
107        assert_eq!(
108            resolved.token.as_ref().map(SecretString::expose_secret),
109            Some("t0ken")
110        );
111    }
112
113    #[tokio::test]
114    async fn an_unreadable_token_source_fails_before_the_host_is_touched() {
115        let connection = Connection::new(
116            Some("ws://127.0.0.1:9222".to_string()),
117            Some(ValueSource::Env("AFHTTP_TEST_ABSENT_TOKEN".to_string())),
118        );
119        let error = connection.resolve().await.expect_err("unset env source");
120        assert!(error.detail.contains("--token-secret"), "{}", error.detail);
121    }
122}