Skip to repository content274 lines · 9.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:03:43.421Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
endpoint.rs
1//! Exo stays on the machine it runs on. `OMEGA-DELTA-0042`, omega#87.
2//!
3//! `exo serve` is a single unary HTTP endpoint with **no authentication**. Its
4//! own documentation says so: a client may send a bearer token and the server
5//! never checks it. It exposes the full 53-variant request protocol, which
6//! includes reading secrets. Loopback is the entire boundary, and Exo knows it.
7//!
8//! Omega therefore treats "where Exo listens" and "where Omega talks to Exo" as
9//! typed values that cannot be built out of a non-loopback address, rather than
10//! as strings a caller is trusted to have checked. The refuse list in the
11//! teardown is stated as a law here: *do not expose Exo's unauthenticated HTTP
12//! endpoint or agent-cli socket beyond loopback through any Omega surface.*
13//!
14//! # Why a parser rather than a check
15//!
16//! A check is something a call site can forget. [`LoopbackEndpoint`] has one
17//! constructor, it takes a string, and it fails. Every value of the type is a
18//! loopback address because there is no other way to make one — the same
19//! discipline `MeasuredDigest` uses for bytes.
20//!
21//! # What "loopback" admits
22//!
23//! `127.0.0.0/8`, `::1`, and the literal name `localhost`. Nothing else. In
24//! particular `0.0.0.0` and `::` are refused, and those are the two that matter:
25//! they are the *plausible* mistakes. They read as "local" and they mean "every
26//! interface on this machine", which on a laptop that joins a Tailnet or a café
27//! network publishes an unauthenticated agent with a shell to that network.
28
29/// An address Omega will let Exo be reached at.
30///
31/// Loopback by construction. See the module documentation.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct LoopbackEndpoint {
34 host: String,
35 port: Option<u16>,
36}
37
38/// Why an address was refused.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum OffLoopback {
41 /// The address names every interface — `0.0.0.0` or `::`.
42 EveryInterface,
43 /// The address names a specific host that is not this machine.
44 RemoteHost,
45 /// The string is not an address this build can read. Unreadable is refused
46 /// rather than assumed local, which is the fail-closed direction.
47 Unreadable,
48}
49
50impl std::fmt::Display for OffLoopback {
51 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 formatter.write_str(match self {
53 Self::EveryInterface => {
54 "that address publishes Exo on every interface, and Exo has no authentication"
55 }
56 Self::RemoteHost => "Omega only reaches Exo on this machine",
57 Self::Unreadable => "that is not an address Omega can read as loopback",
58 })
59 }
60}
61
62impl std::error::Error for OffLoopback {}
63
64/// Where `exo serve` listens when nobody says otherwise.
65///
66/// Exo's own default, restated here so the lane's default is a value in Omega's
67/// source rather than a behaviour inherited from an unstable upstream.
68pub const EXO_SERVE_DEFAULT_BIND: &str = "127.0.0.1:4766";
69
70impl LoopbackEndpoint {
71 /// Read an address, admitting only loopback.
72 ///
73 /// # Errors
74 ///
75 /// [`OffLoopback`] for anything that is not this machine.
76 pub fn parse(address: &str) -> Result<Self, OffLoopback> {
77 let address = address.trim();
78 if address.is_empty() {
79 return Err(OffLoopback::Unreadable);
80 }
81
82 // A URL is reduced to its authority before anything else, so
83 // `http://127.0.0.1:4766/request` and `127.0.0.1:4766` are the same
84 // decision. A scheme this build does not know is unreadable, not
85 // assumed harmless.
86 let authority = match address.split_once("://") {
87 Some((scheme, rest)) => {
88 if !matches!(scheme, "http" | "https") {
89 return Err(OffLoopback::Unreadable);
90 }
91 rest.split(['/', '?', '#']).next().unwrap_or(rest)
92 }
93 None => address,
94 };
95 if authority.contains('@') {
96 // Userinfo can hide the real host behind an `@`. Refuse rather than
97 // pick a side of it.
98 return Err(OffLoopback::Unreadable);
99 }
100
101 let (host, port) = split_host_port(authority)?;
102 match classify_host(&host) {
103 HostKind::Loopback => Ok(Self { host, port }),
104 HostKind::EveryInterface => Err(OffLoopback::EveryInterface),
105 HostKind::Remote => Err(OffLoopback::RemoteHost),
106 HostKind::Unreadable => Err(OffLoopback::Unreadable),
107 }
108 }
109
110 /// The host, as read.
111 #[must_use]
112 pub fn host(&self) -> &str {
113 &self.host
114 }
115
116 /// The port, when the address carried one.
117 #[must_use]
118 pub const fn port(&self) -> Option<u16> {
119 self.port
120 }
121}
122
123impl std::fmt::Display for LoopbackEndpoint {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self.port {
126 Some(port) if self.host.contains(':') => write!(formatter, "[{}]:{port}", self.host),
127 Some(port) => write!(formatter, "{}:{port}", self.host),
128 None => formatter.write_str(&self.host),
129 }
130 }
131}
132
133enum HostKind {
134 Loopback,
135 EveryInterface,
136 Remote,
137 Unreadable,
138}
139
140fn split_host_port(authority: &str) -> Result<(String, Option<u16>), OffLoopback> {
141 if let Some(rest) = authority.strip_prefix('[') {
142 let (host, tail) = rest.split_once(']').ok_or(OffLoopback::Unreadable)?;
143 let port = match tail.strip_prefix(':') {
144 Some(port) => Some(port.parse().map_err(|_| OffLoopback::Unreadable)?),
145 None if tail.is_empty() => None,
146 None => return Err(OffLoopback::Unreadable),
147 };
148 return Ok((host.to_ascii_lowercase(), port));
149 }
150 // A bare IPv6 literal carries colons and no port.
151 if authority.matches(':').count() > 1 {
152 return Ok((authority.to_ascii_lowercase(), None));
153 }
154 match authority.split_once(':') {
155 Some((host, port)) => Ok((
156 host.to_ascii_lowercase(),
157 Some(port.parse().map_err(|_| OffLoopback::Unreadable)?),
158 )),
159 None => Ok((authority.to_ascii_lowercase(), None)),
160 }
161}
162
163fn classify_host(host: &str) -> HostKind {
164 if host == "localhost" {
165 return HostKind::Loopback;
166 }
167 if host == "0.0.0.0" || host == "::" || host == "[::]" {
168 return HostKind::EveryInterface;
169 }
170 if host == "::1" {
171 return HostKind::Loopback;
172 }
173 let octets: Vec<&str> = host.split('.').collect();
174 if octets.len() == 4 && octets.iter().all(|part| part.parse::<u8>().is_ok()) {
175 return if octets[0] == "127" {
176 HostKind::Loopback
177 } else {
178 HostKind::Remote
179 };
180 }
181 if host.contains(':') {
182 // Any other IPv6 literal. `::ffff:0.0.0.0` and friends are deliberately
183 // not decoded into their embedded IPv4 form: a lane that tried would be
184 // making a routing judgement, and refusing is the fail-closed answer.
185 return HostKind::Remote;
186 }
187 if host.is_empty() {
188 return HostKind::Unreadable;
189 }
190 HostKind::Remote
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn exos_own_default_bind_is_loopback_and_is_admitted() {
199 let endpoint = LoopbackEndpoint::parse(EXO_SERVE_DEFAULT_BIND).expect("loopback");
200 assert_eq!(endpoint.host(), "127.0.0.1");
201 assert_eq!(endpoint.port(), Some(4766));
202 assert_eq!(endpoint.to_string(), EXO_SERVE_DEFAULT_BIND);
203 }
204
205 #[test]
206 fn every_loopback_spelling_is_admitted() {
207 for spelling in [
208 "127.0.0.1",
209 "127.0.0.1:4766",
210 "127.1.2.3:4766",
211 "localhost:4766",
212 "LOCALHOST",
213 "::1",
214 "[::1]:4766",
215 "http://127.0.0.1:4766/request",
216 "http://localhost:4766",
217 ] {
218 assert!(
219 LoopbackEndpoint::parse(spelling).is_ok(),
220 "{spelling} is loopback"
221 );
222 }
223 }
224
225 /// The two plausible mistakes. Both read as "local" and neither is.
226 #[test]
227 fn binding_every_interface_is_refused() {
228 for spelling in ["0.0.0.0:4766", "0.0.0.0", "::", "[::]:4766"] {
229 assert_eq!(
230 LoopbackEndpoint::parse(spelling),
231 Err(OffLoopback::EveryInterface),
232 "{spelling}"
233 );
234 }
235 }
236
237 #[test]
238 fn a_remote_host_is_refused() {
239 for spelling in [
240 "10.0.0.4:4766",
241 "100.64.7.9:4766",
242 "192.168.1.20",
243 "exo.example.com:4766",
244 "http://exo.openagents.com/request",
245 "[fd7a:115c::1]:4766",
246 ] {
247 assert_eq!(
248 LoopbackEndpoint::parse(spelling),
249 Err(OffLoopback::RemoteHost),
250 "{spelling}"
251 );
252 }
253 }
254
255 /// A host hidden behind userinfo, an unknown scheme, and an empty string
256 /// are refused rather than guessed at. Unreadable fails closed.
257 #[test]
258 fn an_address_this_build_cannot_read_is_refused_rather_than_assumed_local() {
259 for spelling in [
260 "http://127.0.0.1@evil.example.com/request",
261 "ssh://127.0.0.1:4766",
262 "",
263 " ",
264 "127.0.0.1:notaport",
265 ] {
266 assert_eq!(
267 LoopbackEndpoint::parse(spelling),
268 Err(OffLoopback::Unreadable),
269 "{spelling:?}"
270 );
271 }
272 }
273}
274