Skip to main content

agent_core/core/auth/
broker_token.rs

1//! Zero-config machine token for a loopback `synaps auth-broker` (#147 C1).
2//!
3//! A loopback broker started without `--machine-token-file` /
4//! `SYNAPS_BROKER_TOKEN` generates a random token into
5//! `<base_dir>/auth-broker.token` (0600, next to `auth.json`) and requires
6//! it like any other machine token. A client whose broker endpoint is a
7//! loopback URL and that has no `auth.machine_token` /
8//! `SYNAPS_MACHINE_TOKEN` reads the same file, so `synaps` → local broker
9//! keeps working with no extra configuration for the same user/base dir.
10
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14/// File name of the auto-generated loopback broker token.
15pub const LOCAL_BROKER_TOKEN_FILE: &str = "auth-broker.token";
16
17/// `<base_dir>/auth-broker.token`.
18pub fn local_broker_token_path() -> PathBuf {
19    crate::config::base_dir().join(LOCAL_BROKER_TOKEN_FILE)
20}
21
22/// True when `endpoint` is an http(s) URL whose host is loopback
23/// (`localhost`, `127.0.0.0/8`, `::1`).
24pub fn is_loopback_endpoint(endpoint: &str) -> bool {
25    let Ok(url) = url::Url::parse(endpoint.trim()) else {
26        return false;
27    };
28    if !matches!(url.scheme(), "http" | "https") {
29        return false;
30    }
31    match url.host() {
32        Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
33        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
34        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
35        None => false,
36    }
37}
38
39/// Client side: the local broker token, if the file exists and is non-empty.
40pub fn read_local_broker_token() -> Option<String> {
41    read_token_at(&local_broker_token_path()).ok().flatten()
42}
43
44/// Largest token file a client will read (the auto token is 65 bytes).
45const MAX_CLIENT_TOKEN_FILE: u64 = 4096;
46
47/// Client read of the token file: never follows a symlink, refuses anything
48/// but a regular file, and caps the size, so whatever `auth-broker.token`
49/// points at is never sent as a bearer.
50fn read_token_at(path: &Path) -> std::io::Result<Option<String>> {
51    use std::io::Read;
52    let meta = match std::fs::symlink_metadata(path) {
53        Ok(m) => m,
54        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
55        Err(e) => return Err(e),
56    };
57    if meta.file_type().is_symlink() {
58        return Err(invalid(path, "is a symlink; refusing to follow it"));
59    }
60    if !meta.is_file() {
61        return Err(invalid(path, "is not a regular file"));
62    }
63    if meta.len() > MAX_CLIENT_TOKEN_FILE {
64        return Err(invalid(path, "is larger than 4 KiB; not a machine token"));
65    }
66    let mut opts = std::fs::OpenOptions::new();
67    opts.read(true);
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::OpenOptionsExt;
71        opts.custom_flags(libc::O_NOFOLLOW);
72    }
73    let mut s = String::new();
74    opts.open(path)?
75        .take(MAX_CLIENT_TOKEN_FILE)
76        .read_to_string(&mut s)?;
77    Ok(Some(s.trim().to_string()).filter(|t| !t.is_empty()))
78}
79
80/// How [`load_or_create_broker_token`] obtained the token.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum BrokerTokenOrigin {
83    /// Reused the existing private file.
84    Loaded,
85    /// No file: generated a new token.
86    Created,
87    /// The existing file was readable by group/other, so its token may have
88    /// leaked: a NEW token replaced it (clients must pick it up).
89    Rotated,
90}
91
92fn invalid(path: &Path, why: &str) -> std::io::Error {
93    std::io::Error::new(
94        std::io::ErrorKind::InvalidData,
95        format!("broker token file {}: {why}", path.display()),
96    )
97}
98
99/// Broker side: read the token at `path`, or create it when absent.
100///
101/// Hardening (prA I3):
102/// - a symlink is refused (never followed), as is anything but a regular
103///   file, or a file owned by another user;
104/// - a file readable by group/other is treated as leaked: a NEW token is
105///   written (atomically, 0600) instead of chmod-ing and reusing the old one;
106/// - creation writes a 0600 temp file and hard-links it into place, so a
107///   concurrent starter never sees a half-written file, and the loser reads
108///   the winner's token;
109/// - an empty existing file is an error (delete it to regenerate).
110pub fn load_or_create_broker_token(path: &Path) -> std::io::Result<(String, BrokerTokenOrigin)> {
111    if let Some(parent) = path.parent() {
112        create_private_dir(parent)?;
113    }
114    sweep_stale_temp_files(path, STALE_TEMP_AGE);
115    for _ in 0..3 {
116        match std::fs::symlink_metadata(path) {
117            Ok(meta) => {
118                if meta.file_type().is_symlink() {
119                    return Err(invalid(path, "is a symlink; refusing to follow it"));
120                }
121                if !meta.is_file() {
122                    return Err(invalid(path, "is not a regular file"));
123                }
124                check_owner(path, &meta)?;
125                if loose_mode(&meta) {
126                    let token = generate_token();
127                    write_atomic_replace(path, &token)?;
128                    return Ok((token, BrokerTokenOrigin::Rotated));
129                }
130                let token = read_no_follow(path)?;
131                if token.is_empty() {
132                    return Err(invalid(path, "is empty; delete it to generate a new one"));
133                }
134                return Ok((token, BrokerTokenOrigin::Loaded));
135            }
136            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
137                let token = generate_token();
138                match write_new_exclusive(path, &token) {
139                    Ok(()) => return Ok((token, BrokerTokenOrigin::Created)),
140                    // A concurrent broker won the race: read its token.
141                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
142                    Err(e) => return Err(e),
143                }
144            }
145            Err(e) => return Err(e),
146        }
147    }
148    Err(std::io::Error::other(format!(
149        "could not create or read broker token file {}",
150        path.display()
151    )))
152}
153
154#[cfg(unix)]
155fn check_owner(path: &Path, meta: &std::fs::Metadata) -> std::io::Result<()> {
156    use std::os::unix::fs::MetadataExt;
157    // SAFETY: geteuid has no preconditions and cannot fail.
158    if meta.uid() != unsafe { libc::geteuid() } {
159        return Err(invalid(path, "is owned by another user; refusing to use it"));
160    }
161    Ok(())
162}
163#[cfg(not(unix))]
164fn check_owner(_: &Path, _: &std::fs::Metadata) -> std::io::Result<()> {
165    Ok(())
166}
167
168#[cfg(unix)]
169fn loose_mode(meta: &std::fs::Metadata) -> bool {
170    use std::os::unix::fs::PermissionsExt;
171    meta.permissions().mode() & 0o077 != 0
172}
173#[cfg(not(unix))]
174fn loose_mode(_: &std::fs::Metadata) -> bool {
175    false
176}
177
178fn read_no_follow(path: &Path) -> std::io::Result<String> {
179    use std::io::Read;
180    let mut opts = std::fs::OpenOptions::new();
181    opts.read(true);
182    #[cfg(unix)]
183    {
184        use std::os::unix::fs::OpenOptionsExt;
185        opts.custom_flags(libc::O_NOFOLLOW);
186    }
187    let mut s = String::new();
188    opts.open(path)?.read_to_string(&mut s)?;
189    Ok(s.trim().to_string())
190}
191
192/// Write `token` to a fresh 0600 temp file beside `path`.
193fn write_temp(path: &Path, token: &str) -> std::io::Result<PathBuf> {
194    let name = path
195        .file_name()
196        .map(|n| n.to_string_lossy().into_owned())
197        .unwrap_or_else(|| LOCAL_BROKER_TOKEN_FILE.to_string());
198    let tmp = path.with_file_name(format!(
199        ".{name}.tmp.{}.{:08x}",
200        std::process::id(),
201        rand::random::<u32>()
202    ));
203    let mut opts = std::fs::OpenOptions::new();
204    opts.write(true).create_new(true);
205    #[cfg(unix)]
206    {
207        use std::os::unix::fs::OpenOptionsExt;
208        opts.mode(0o600);
209    }
210    let mut f = opts.open(&tmp)?;
211    let written = f
212        .write_all(token.as_bytes())
213        .and_then(|_| f.write_all(b"\n"))
214        .and_then(|_| f.sync_all());
215    if let Err(e) = written {
216        let _ = std::fs::remove_file(&tmp);
217        return Err(e);
218    }
219    Ok(tmp)
220}
221
222/// A temp token file older than this was left by a crashed writer (a live
223/// writer holds one for milliseconds).
224const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(60);
225
226/// Remove leftover `.<name>.tmp.*` files beside `path` older than `age`.
227/// They are 0600 but hold a token, so they should not linger. Best-effort.
228fn sweep_stale_temp_files(path: &Path, age: std::time::Duration) {
229    let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
230        return;
231    };
232    let prefix = format!(".{}.tmp.", name.to_string_lossy());
233    let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
234    let Ok(entries) = std::fs::read_dir(dir) else {
235        return;
236    };
237    for entry in entries.flatten() {
238        if !entry.file_name().to_string_lossy().starts_with(&prefix) {
239            continue;
240        }
241        let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
242            continue;
243        };
244        let old = meta
245            .modified()
246            .ok()
247            .and_then(|m| m.elapsed().ok())
248            .is_some_and(|e| e >= age);
249        if meta.is_file() && old {
250            let _ = std::fs::remove_file(entry.path());
251        }
252    }
253}
254
255/// Create `path` only if absent (hard link of a complete temp file). On a
256/// filesystem without hard links, fall back to an exclusive create.
257/// That fallback reopens a small window in which a concurrent starter can
258/// read the file before the token is written (it then fails on "empty"
259/// and a restart fixes it); accepted for such filesystems only.
260fn write_new_exclusive(path: &Path, token: &str) -> std::io::Result<()> {
261    let tmp = write_temp(path, token)?;
262    let linked = std::fs::hard_link(&tmp, path);
263    let _ = std::fs::remove_file(&tmp);
264    match linked {
265        Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => {
266            let mut opts = std::fs::OpenOptions::new();
267            opts.write(true).create_new(true);
268            #[cfg(unix)]
269            {
270                use std::os::unix::fs::OpenOptionsExt;
271                opts.mode(0o600);
272            }
273            let mut f = opts.open(path)?;
274            f.write_all(token.as_bytes())?;
275            f.write_all(b"\n")?;
276            f.sync_all()
277        }
278        other => other,
279    }
280}
281
282/// Atomically replace `path` (rename of a complete 0600 temp file).
283fn write_atomic_replace(path: &Path, token: &str) -> std::io::Result<()> {
284    let tmp = write_temp(path, token)?;
285    std::fs::rename(&tmp, path).inspect_err(|_| {
286        let _ = std::fs::remove_file(&tmp);
287    })
288}
289
290fn generate_token() -> String {
291    let bytes: [u8; 32] = rand::random();
292    bytes.iter().map(|b| format!("{b:02x}")).collect()
293}
294
295fn create_private_dir(dir: &Path) -> std::io::Result<()> {
296    if dir.as_os_str().is_empty() || dir.exists() {
297        return Ok(());
298    }
299    let mut builder = std::fs::DirBuilder::new();
300    builder.recursive(true);
301    #[cfg(unix)]
302    {
303        use std::os::unix::fs::DirBuilderExt;
304        builder.mode(0o700);
305    }
306    builder.create(dir)
307}
308
309/// The machine token a client presents to a broker, with its provenance.
310///
311/// - Explicit (`auth.machine_token` / `SYNAPS_MACHINE_TOKEN`, i.e. a
312///   non-empty configured token): fixed. Never replaced by, and never sent
313///   alongside, the local file token (prA N1) — even on a loopback endpoint,
314///   which may be an `ssh -L` tunnel to a remote broker.
315/// - None configured + loopback endpoint: the local broker's
316///   `<base_dir>/auth-broker.token`, read now and re-read after a 401
317///   (prA I2), so a long-lived client started before the broker's first run,
318///   or across a rotation, recovers without a restart.
319/// - None configured + remote endpoint: empty, never reads the file.
320///
321/// Clones share the current value.
322#[derive(Clone)]
323pub struct MachineToken {
324    current: std::sync::Arc<std::sync::RwLock<String>>,
325    reload_from: Option<PathBuf>,
326}
327
328impl MachineToken {
329    /// `token` is the EXPLICITLY configured token ("" = none configured).
330    pub fn for_endpoint(endpoint: &str, token: impl Into<String>) -> Self {
331        let token = token.into().trim().to_string();
332        if !token.is_empty() || !is_loopback_endpoint(endpoint) {
333            return Self::fixed(token);
334        }
335        let path = local_broker_token_path();
336        let initial = read_token_at(&path).ok().flatten().unwrap_or_default();
337        Self {
338            current: std::sync::Arc::new(std::sync::RwLock::new(initial)),
339            reload_from: Some(path),
340        }
341    }
342
343    /// Whether this token comes from the local broker token file.
344    pub fn is_from_local_file(&self) -> bool {
345        self.reload_from.is_some()
346    }
347
348    /// Fixed token, never reloaded.
349    pub fn fixed(token: impl Into<String>) -> Self {
350        Self {
351            current: std::sync::Arc::new(std::sync::RwLock::new(token.into())),
352            reload_from: None,
353        }
354    }
355
356    pub fn get(&self) -> String {
357        self.current
358            .read()
359            .unwrap_or_else(|e| e.into_inner())
360            .clone()
361    }
362
363    /// After a 401: re-read the local broker token file. Returns true when
364    /// a different non-empty token was found (and is now current).
365    pub fn reload_after_unauthorized(&self) -> bool {
366        let Some(path) = &self.reload_from else {
367            return false;
368        };
369        let Ok(Some(fresh)) = read_token_at(path) else {
370            return false;
371        };
372        let mut cur = self.current.write().unwrap_or_else(|e| e.into_inner());
373        if *cur == fresh {
374            return false;
375        }
376        *cur = fresh;
377        true
378    }
379
380    /// Send a request built with the current token; on a 401 from a
381    /// loopback broker whose token file has changed, retry once with it.
382    pub async fn send(
383        &self,
384        make: impl Fn(&str) -> reqwest::RequestBuilder,
385    ) -> reqwest::Result<reqwest::Response> {
386        let resp = make(&self.get()).send().await?;
387        if resp.status() == reqwest::StatusCode::UNAUTHORIZED && self.reload_after_unauthorized() {
388            drop(resp);
389            return make(&self.get()).send().await;
390        }
391        Ok(resp)
392    }
393}
394
395/// Actionable text for a broker 401 (client side).
396pub fn unauthorized_hint() -> String {
397    format!(
398        "broker rejected machine auth (401). Set auth.machine_token or \
399         SYNAPS_MACHINE_TOKEN to the broker's token; a loopback broker started \
400         without one writes it to {} on the broker host (same user and base dir \
401         are picked up automatically). If the broker regenerated it, restart or \
402         reload this client.",
403        local_broker_token_path().display()
404    )
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn loopback_endpoints_are_recognised() {
413        for e in [
414            "http://127.0.0.1:8181",
415            "http://localhost:8181/",
416            "https://LOCALHOST:9",
417            "http://[::1]:8181",
418            "http://127.1.2.3:1",
419        ] {
420            assert!(is_loopback_endpoint(e), "{e}");
421        }
422        for e in [
423            "https://jade.jade:8181",
424            "http://10.0.0.1:8181",
425            "http://localhost.evil.com:8181",
426            "ftp://127.0.0.1",
427            "not a url",
428            "",
429        ] {
430            assert!(!is_loopback_endpoint(e), "{e}");
431        }
432    }
433
434    #[test]
435    fn creates_a_private_random_token_once_and_reuses_it() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("nested").join(LOCAL_BROKER_TOKEN_FILE);
438        let (token, origin) = load_or_create_broker_token(&path).unwrap();
439        assert_eq!(origin, BrokerTokenOrigin::Created);
440        assert_eq!(token.len(), 64, "256-bit hex");
441        assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
442        #[cfg(unix)]
443        {
444            use std::os::unix::fs::PermissionsExt;
445            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
446            assert_eq!(mode & 0o777, 0o600, "token file must be 0600");
447            let dir_mode = std::fs::metadata(path.parent().unwrap())
448                .unwrap()
449                .permissions()
450                .mode();
451            assert_eq!(dir_mode & 0o777, 0o700, "created dir must be 0700");
452        }
453        let (again, origin) = load_or_create_broker_token(&path).unwrap();
454        assert_eq!(origin, BrokerTokenOrigin::Loaded);
455        assert_eq!(again, token, "restart keeps the same token");
456        let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
457            .unwrap()
458            .filter_map(|e| e.ok())
459            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
460            .collect();
461        assert!(leftovers.is_empty(), "no temp files left behind");
462        let (other, _) = load_or_create_broker_token(&dir.path().join("other.token")).unwrap();
463        assert_ne!(other, token, "tokens are random");
464    }
465
466    /// prA I3: a group/other-readable token may already have leaked; it is
467    /// replaced by a NEW token (0600), not chmod-ed and reused.
468    #[cfg(unix)]
469    #[test]
470    fn loose_token_file_is_rotated_not_reused() {
471        use std::os::unix::fs::PermissionsExt;
472        let dir = tempfile::tempdir().unwrap();
473        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
474        std::fs::write(&path, "leaked-token\n").unwrap();
475        for mode in [0o644, 0o640, 0o604] {
476            std::fs::write(&path, "leaked-token\n").unwrap();
477            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
478            let (token, origin) = load_or_create_broker_token(&path).unwrap();
479            assert_eq!(origin, BrokerTokenOrigin::Rotated, "{mode:o}");
480            assert_ne!(token, "leaked-token", "{mode:o}");
481            assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), token);
482            let now = std::fs::metadata(&path).unwrap().permissions().mode();
483            assert_eq!(now & 0o777, 0o600, "{mode:o}");
484        }
485        std::fs::write(&path, "  \n").unwrap();
486        assert!(load_or_create_broker_token(&path).is_err(), "empty file refused");
487    }
488
489    #[cfg(unix)]
490    #[test]
491    fn symlinked_token_file_is_refused() {
492        let dir = tempfile::tempdir().unwrap();
493        let target = dir.path().join("attacker-controlled");
494        std::fs::write(&target, "attacker-token\n").unwrap();
495        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
496        std::os::unix::fs::symlink(&target, &path).unwrap();
497        let err = load_or_create_broker_token(&path).unwrap_err();
498        assert!(err.to_string().contains("symlink"), "{err}");
499        // A dangling symlink is refused too (never created through).
500        std::fs::remove_file(&target).unwrap();
501        assert!(load_or_create_broker_token(&path).is_err());
502        assert!(!target.exists(), "never written through the link");
503    }
504
505    #[test]
506    fn non_regular_token_path_is_refused() {
507        let dir = tempfile::tempdir().unwrap();
508        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
509        std::fs::create_dir(&path).unwrap();
510        assert!(load_or_create_broker_token(&path).is_err());
511    }
512
513    /// Owner check: a file owned by another uid is refused. Needs root to
514    /// chown, so it is exercised only when running as root.
515    #[cfg(unix)]
516    #[test]
517    fn foreign_owned_token_file_is_refused_when_testable() {
518        // SAFETY: geteuid has no preconditions.
519        if unsafe { libc::geteuid() } != 0 {
520            return;
521        }
522        use std::os::unix::fs::PermissionsExt;
523        let dir = tempfile::tempdir().unwrap();
524        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
525        std::fs::write(&path, "t\n").unwrap();
526        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
527        std::os::unix::fs::chown(&path, Some(65534), None).unwrap();
528        let err = load_or_create_broker_token(&path).unwrap_err();
529        assert!(err.to_string().contains("another user"), "{err}");
530    }
531
532    #[test]
533    fn stale_temp_files_are_swept_fresh_ones_kept() {
534        let dir = tempfile::tempdir().unwrap();
535        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
536        let stale = dir.path().join(".auth-broker.token.tmp.1.deadbeef");
537        let fresh = dir.path().join(".auth-broker.token.tmp.2.cafebabe");
538        let other = dir.path().join("unrelated.tmp.1");
539        for f in [&stale, &fresh, &other] {
540            std::fs::write(f, "token\n").unwrap();
541        }
542        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
543        for f in [&stale, &other] {
544            std::fs::File::options()
545                .write(true)
546                .open(f)
547                .unwrap()
548                .set_modified(old)
549                .unwrap();
550        }
551        let (_, origin) = load_or_create_broker_token(&path).unwrap();
552        assert_eq!(origin, BrokerTokenOrigin::Created);
553        assert!(!stale.exists(), "crashed writer's temp file is removed");
554        assert!(fresh.exists(), "a live writer's temp file is left alone");
555        assert!(other.exists(), "unrelated files untouched");
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn client_read_refuses_symlinks_and_oversized_files() {
561        let dir = tempfile::tempdir().unwrap();
562        let target = dir.path().join("elsewhere");
563        std::fs::write(&target, "secret-elsewhere\n").unwrap();
564        let link = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
565        std::os::unix::fs::symlink(&target, &link).unwrap();
566        assert!(read_token_at(&link).is_err(), "symlink never followed");
567        let big = dir.path().join("big.token");
568        std::fs::write(&big, "a".repeat(5000)).unwrap();
569        assert!(read_token_at(&big).is_err(), "over 4 KiB refused");
570        let ok = dir.path().join("ok.token");
571        std::fs::write(&ok, "tok\n").unwrap();
572        assert_eq!(read_token_at(&ok).unwrap().as_deref(), Some("tok"));
573        assert_eq!(read_token_at(&dir.path().join("missing")).unwrap(), None);
574    }
575
576    /// prA I2: a loopback client re-reads the token file after a 401.
577    #[test]
578    fn machine_token_reloads_only_for_loopback_and_only_when_changed() {
579        let dir = tempfile::tempdir().unwrap();
580        let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
581        let tok = MachineToken {
582            current: std::sync::Arc::new(std::sync::RwLock::new(String::new())),
583            reload_from: Some(path.clone()),
584        };
585        assert!(!tok.reload_after_unauthorized(), "no file yet");
586        std::fs::write(&path, "fresh\n").unwrap();
587        let shared = tok.clone();
588        assert!(tok.reload_after_unauthorized());
589        assert_eq!(shared.get(), "fresh", "clones share the reloaded token");
590        assert!(!tok.reload_after_unauthorized(), "unchanged → no retry");
591        let remote = MachineToken::for_endpoint("https://jade.jade:8181", "");
592        assert!(!remote.reload_after_unauthorized());
593        // prA N1: an explicit token is fixed, even on loopback.
594        let explicit = MachineToken::for_endpoint("http://127.0.0.1:8181", "x");
595        assert!(!explicit.is_from_local_file());
596        assert!(!explicit.reload_after_unauthorized());
597        assert_eq!(explicit.get(), "x");
598        assert!(MachineToken::for_endpoint("http://127.0.0.1:8181", "").is_from_local_file());
599    }
600}