Skip to main content

start_command/
formal_ai_runtime.rs

1//! Resolution and identity of the Formal AI runtime used by `--use-formal-ai`
2//! (issue #255). Mirrors js/src/lib/formal-ai-runtime.js.
3//!
4//! Formal AI is not published to a registry, so start never installs or
5//! guesses a runtime. It uses exactly one of:
6//! - START_FORMAL_AI_ROOT: a link-assistant/formal-ai checkout whose
7//!   JavaScript server (`js/server/main.mjs --agent-mode`) start spawns on a
8//!   private loopback port with a private memory directory;
9//! - START_FORMAL_AI_URL: an already running Formal AI server, which reports
10//!   no version, so it is used only with START_FORMAL_AI_ALLOW_UNVERIFIED=1.
11//!
12//! Without either, or with a runtime older than the qualified version, the
13//! mode fails; it never falls back to an LLM, the alias table or a shell.
14
15use std::io::Read;
16use std::net::TcpListener;
17use std::path::{Path, PathBuf};
18use std::process::{Child, Command, Stdio};
19use std::sync::{Arc, Mutex};
20use std::time::{Duration, Instant};
21
22use serde_json::{json, Value};
23
24use crate::formal_ai_client::{http_request, HttpError, API_PREFIX};
25use crate::formal_ai_policy::Bounds;
26
27/// The Formal AI release this mode was qualified against.
28pub const MIN_FORMAL_AI_VERSION: &str = "0.355.27";
29pub const SERVER_ENTRY: &str = "js/server/main.mjs";
30pub const INSTALL_HINT: &str = "Install Formal AI from https://github.com/link-assistant/formal-ai (git clone https://github.com/link-assistant/formal-ai.git) and set START_FORMAL_AI_ROOT to the checkout, or set START_FORMAL_AI_URL to a running server. See docs/formal-ai.md.";
31
32/// Compare dotted numeric versions; None when either is not one.
33pub fn compare_versions(left: &str, right: &str) -> Option<std::cmp::Ordering> {
34    let parse = |v: &str| -> Option<Vec<u64>> {
35        if v.is_empty() {
36            return None;
37        }
38        v.split('.')
39            .map(|part| {
40                if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
41                    part.parse::<u64>().ok()
42                } else {
43                    None
44                }
45            })
46            .collect()
47    };
48    let a = parse(left)?;
49    let b = parse(right)?;
50    for i in 0..a.len().max(b.len()) {
51        let order = a.get(i).unwrap_or(&0).cmp(b.get(i).unwrap_or(&0));
52        if order != std::cmp::Ordering::Equal {
53            return Some(order);
54        }
55    }
56    Some(std::cmp::Ordering::Equal)
57}
58
59/// `version` of the `[package]` table of `<root>/rust/Cargo.toml`.
60pub fn read_cargo_version(root: &Path) -> Option<String> {
61    let text = std::fs::read_to_string(root.join("rust").join("Cargo.toml")).ok()?;
62    let mut in_package = false;
63    for line in text.lines() {
64        if line.starts_with('[') {
65            in_package = line.starts_with("[package]");
66            continue;
67        }
68        if !in_package {
69            continue;
70        }
71        if let Some(rest) = line.strip_prefix("version") {
72            let rest = rest.trim_start();
73            if let Some(value) = rest.strip_prefix('=') {
74                let value = value.trim();
75                if let Some(inner) = value.strip_prefix('"') {
76                    if let Some(end) = inner.find('"') {
77                        if end > 0 {
78                            return Some(inner[..end].to_string());
79                        }
80                    }
81                }
82            }
83        }
84    }
85    None
86}
87
88fn read_json_version(file: &Path) -> Option<String> {
89    let text = std::fs::read_to_string(file).ok()?;
90    let value: Value = serde_json::from_str(&text).ok()?;
91    value
92        .get("version")
93        .and_then(Value::as_str)
94        .filter(|v| !v.is_empty())
95        .map(str::to_string)
96}
97
98fn run_text(program: &str, args: &[&str]) -> Option<String> {
99    let output = Command::new(program)
100        .args(args)
101        .stdin(Stdio::null())
102        .output()
103        .ok()?;
104    if output.status.success() {
105        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
106    } else {
107        None
108    }
109}
110
111/// Exact source identity of the runtime, recorded in logs and evidence.
112#[derive(Debug, Clone, PartialEq, Default)]
113pub struct RuntimeIdentity {
114    /// `checkout` or `url`
115    pub source: String,
116    pub url: Option<String>,
117    pub root: Option<String>,
118    pub entry: Option<String>,
119    pub version: Option<String>,
120    pub engine_version: Option<String>,
121    pub commit: Option<String>,
122    pub dirty: Option<bool>,
123    pub node: Option<String>,
124    pub node_version: Option<String>,
125    pub memory: Option<String>,
126    pub port: Option<u16>,
127}
128
129impl RuntimeIdentity {
130    /// Same keys as the JavaScript identity object.
131    pub fn to_json(&self) -> Value {
132        if self.source == "url" {
133            return json!({ "source": "url", "url": self.url });
134        }
135        let mut value = json!({
136            "source": self.source,
137            "root": self.root,
138            "entry": self.entry,
139            "version": self.version,
140            "engineVersion": self.engine_version,
141            "commit": self.commit,
142            "dirty": self.dirty,
143            "node": self.node,
144            "nodeVersion": self.node_version,
145        });
146        if let Some(memory) = &self.memory {
147            value["memory"] = json!(memory);
148        }
149        if let Some(port) = self.port {
150            value["port"] = json!(port);
151        }
152        value
153    }
154}
155
156/// Source identity of a Formal AI checkout.
157pub fn checkout_identity(root: &Path, node: &str) -> RuntimeIdentity {
158    let root_text = root.to_string_lossy().to_string();
159    let commit = run_text("git", &["-C", &root_text, "rev-parse", "HEAD"]);
160    let status = commit
161        .as_ref()
162        .and_then(|_| run_text("git", &["-C", &root_text, "status", "--porcelain"]));
163    RuntimeIdentity {
164        source: "checkout".to_string(),
165        root: Some(root_text),
166        entry: Some(SERVER_ENTRY.to_string()),
167        version: read_cargo_version(root),
168        engine_version: read_json_version(
169            &root
170                .join("packages")
171                .join("formal-ai-engine")
172                .join("package.json"),
173        ),
174        commit,
175        dirty: status.map(|s| !s.is_empty()),
176        node: Some(node.to_string()),
177        node_version: run_text(node, &["--version"]),
178        ..RuntimeIdentity::default()
179    }
180}
181
182/// One-line rendering of an identity for terminals and logs.
183pub fn describe_identity(identity: &RuntimeIdentity) -> String {
184    if identity.source == "url" {
185        return format!(
186            "formal-ai at {} (version unverified)",
187            identity.url.as_deref().unwrap_or("")
188        );
189    }
190    let commit = match &identity.commit {
191        Some(commit) if !commit.is_empty() => format!(
192            "{}{}",
193            commit.chars().take(12).collect::<String>(),
194            if identity.dirty == Some(true) {
195                "+dirty"
196            } else {
197                ""
198            }
199        ),
200        _ => "no git commit".to_string(),
201    };
202    format!(
203        "formal-ai {} ({}, js server {}, node {})",
204        identity.version.as_deref().unwrap_or("unknown"),
205        commit,
206        identity.entry.as_deref().unwrap_or(SERVER_ENTRY),
207        identity.node_version.as_deref().unwrap_or("unknown")
208    )
209}
210
211/// Check a resolved identity against the qualified version and an optional
212/// START_FORMAL_AI_EXPECTED_REVISION pin.
213pub fn qualify_identity(
214    identity: &RuntimeIdentity,
215    get: &dyn Fn(&str) -> Option<String>,
216) -> Result<(), String> {
217    let expected = get("START_FORMAL_AI_EXPECTED_REVISION")
218        .unwrap_or_default()
219        .trim()
220        .to_string();
221    if identity.source == "url" {
222        if get("START_FORMAL_AI_ALLOW_UNVERIFIED").as_deref() != Some("1") {
223            return Err(format!(
224                "START_FORMAL_AI_URL={} does not report its Formal AI version, so it cannot be qualified. Set START_FORMAL_AI_ALLOW_UNVERIFIED=1 to use it anyway, or use START_FORMAL_AI_ROOT.",
225                identity.url.as_deref().unwrap_or("")
226            ));
227        }
228        return Ok(());
229    }
230    if identity.node_version.is_none() {
231        return Err(format!(
232            "Cannot run \"{} --version\"; set START_FORMAL_AI_NODE to a Node.js executable.",
233            identity.node.as_deref().unwrap_or("node")
234        ));
235    }
236    let root = identity.root.as_deref().unwrap_or("");
237    let version = identity.version.as_deref().unwrap_or("");
238    let order = compare_versions(version, MIN_FORMAL_AI_VERSION);
239    if order.is_none_or(|o| o == std::cmp::Ordering::Less) {
240        return Err(format!(
241            "Formal AI at {} is version {}; --use-formal-ai needs {} or newer. Update the checkout (git pull).",
242            root,
243            if version.is_empty() { "unknown" } else { version },
244            MIN_FORMAL_AI_VERSION
245        ));
246    }
247    let pinned = identity
248        .commit
249        .as_deref()
250        .is_some_and(|commit| commit.starts_with(&expected.to_lowercase()));
251    if !expected.is_empty() && !pinned {
252        return Err(format!(
253            "Formal AI at {} is at commit {}, not START_FORMAL_AI_EXPECTED_REVISION={}.",
254            root,
255            identity.commit.as_deref().unwrap_or("unknown"),
256            expected
257        ));
258    }
259    Ok(())
260}
261
262/// A resolved runtime; a spawned server is stopped on `stop()` or drop.
263pub struct Runtime {
264    pub base_url: String,
265    pub identity: RuntimeIdentity,
266    child: Option<Child>,
267    home: Option<PathBuf>,
268}
269
270impl Runtime {
271    pub fn stop(&mut self) {
272        if let Some(mut child) = self.child.take() {
273            if let Ok(None) = child.try_wait() {
274                let _ = child.kill();
275            }
276            let _ = child.wait();
277        }
278        if let Some(home) = self.home.take() {
279            let _ = std::fs::remove_dir_all(home);
280        }
281    }
282}
283
284impl Drop for Runtime {
285    fn drop(&mut self) {
286        self.stop();
287    }
288}
289
290fn free_port() -> Result<u16, String> {
291    let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
292    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
293    Ok(port)
294}
295
296fn private_home() -> Result<PathBuf, String> {
297    let base = std::env::temp_dir();
298    for attempt in 0..100u32 {
299        let candidate = base.join(format!(
300            "start-formal-ai-{}-{}",
301            std::process::id(),
302            uuid::Uuid::new_v4()
303                .simple()
304                .to_string()
305                .get(..8)
306                .unwrap_or("x")
307        ));
308        match std::fs::create_dir(&candidate) {
309            Ok(()) => return Ok(candidate),
310            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempt < 99 => {}
311            Err(error) => return Err(error.to_string()),
312        }
313    }
314    Err("cannot create a private Formal AI home".to_string())
315}
316
317/// Resolve, qualify and (for a checkout) start the runtime.
318pub fn start_runtime(
319    get: &dyn Fn(&str) -> Option<String>,
320    bounds: &Bounds,
321    cancel: &dyn Fn() -> bool,
322) -> Result<Runtime, String> {
323    let url = get("START_FORMAL_AI_URL")
324        .unwrap_or_default()
325        .trim()
326        .to_string();
327    let root = get("START_FORMAL_AI_ROOT")
328        .unwrap_or_default()
329        .trim()
330        .to_string();
331    if !url.is_empty() && !root.is_empty() {
332        return Err("Set only one of START_FORMAL_AI_URL and START_FORMAL_AI_ROOT.".to_string());
333    }
334    if !url.is_empty() {
335        if !url.starts_with("http://") {
336            return Err(format!(
337                "START_FORMAL_AI_URL must be an http:// URL, got \"{}\"",
338                url
339            ));
340        }
341        let identity = RuntimeIdentity {
342            source: "url".to_string(),
343            url: Some(url.trim_end_matches('/').to_string()),
344            ..RuntimeIdentity::default()
345        };
346        qualify_identity(&identity, get)?;
347        return Ok(Runtime {
348            base_url: identity.url.clone().unwrap_or_default(),
349            identity,
350            child: None,
351            home: None,
352        });
353    }
354    if root.is_empty() {
355        return Err(format!(
356            "--use-formal-ai needs a Formal AI runtime and has no fallback. {}",
357            INSTALL_HINT
358        ));
359    }
360    let absolute_root = std::env::current_dir()
361        .map(|cwd| cwd.join(&root))
362        .unwrap_or_else(|_| PathBuf::from(&root));
363    if !absolute_root.join(SERVER_ENTRY).exists() {
364        return Err(format!(
365            "START_FORMAL_AI_ROOT={} has no {}. {}",
366            root, SERVER_ENTRY, INSTALL_HINT
367        ));
368    }
369    let node = get("START_FORMAL_AI_NODE")
370        .filter(|n| !n.trim().is_empty())
371        .unwrap_or_else(|| "node".to_string())
372        .trim()
373        .to_string();
374    let identity = checkout_identity(&absolute_root, &node);
375    qualify_identity(&identity, get)?;
376    spawn_server(&absolute_root, &node, identity, get, bounds, cancel)
377}
378
379fn spawn_server(
380    root: &Path,
381    node: &str,
382    mut identity: RuntimeIdentity,
383    get: &dyn Fn(&str) -> Option<String>,
384    bounds: &Bounds,
385    cancel: &dyn Fn() -> bool,
386) -> Result<Runtime, String> {
387    let port = free_port()?;
388    let home = private_home()?;
389    let memory = get("START_FORMAL_AI_MEMORY_PATH")
390        .unwrap_or_default()
391        .trim()
392        .to_string();
393    let mut command = Command::new(node);
394    command
395        .arg(format!("--max-old-space-size={}", bounds.max_memory_mb))
396        .arg(root.join(SERVER_ENTRY))
397        .args(["--agent-mode", "--host", "127.0.0.1", "--port"])
398        .arg(port.to_string())
399        .arg("--silent")
400        .current_dir(root)
401        .env_clear()
402        .env("PATH", get("PATH").unwrap_or_default())
403        .env("HOME", &home)
404        .env(
405            "FORMAL_AI_MEMORY_PATH",
406            if memory.is_empty() {
407                home.join("memory.lino")
408            } else {
409                PathBuf::from(&memory)
410            },
411        )
412        .stdin(Stdio::null())
413        .stdout(Stdio::null())
414        .stderr(Stdio::piped());
415    for name in ["LANG", "SYSTEMROOT"] {
416        if let Some(value) = get(name).filter(|v| !v.is_empty()) {
417            command.env(name, value);
418        }
419    }
420    identity.memory = Some(
421        if memory.is_empty() {
422            "private"
423        } else {
424            "START_FORMAL_AI_MEMORY_PATH"
425        }
426        .to_string(),
427    );
428    let mut runtime = Runtime {
429        base_url: format!("http://127.0.0.1:{}", port),
430        identity,
431        child: None,
432        home: Some(home),
433    };
434    let stderr = Arc::new(Mutex::new(String::new()));
435    let mut child = match command.spawn() {
436        Ok(child) => child,
437        Err(error) => {
438            return Err(format!(
439                "Formal AI server did not become ready in time ({})",
440                error
441            ))
442        }
443    };
444    if let Some(mut pipe) = child.stderr.take() {
445        let sink = Arc::clone(&stderr);
446        std::thread::spawn(move || {
447            let mut buf = [0u8; 4096];
448            while let Ok(n) = pipe.read(&mut buf) {
449                if n == 0 {
450                    break;
451                }
452                if let Ok(mut text) = sink.lock() {
453                    text.push_str(&String::from_utf8_lossy(&buf[..n]));
454                    let excess = text.len().saturating_sub(4096);
455                    if excess > 0 {
456                        let cut = (excess..=text.len())
457                            .find(|i| text.is_char_boundary(*i))
458                            .unwrap_or(text.len());
459                        text.drain(..cut);
460                    }
461                }
462            }
463        });
464    }
465    runtime.child = Some(child);
466    let deadline = Instant::now() + Duration::from_millis(bounds.startup_timeout_ms);
467    if let Err(message) = wait_ready(&mut runtime, deadline, cancel) {
468        runtime.stop();
469        let tail = stderr
470            .lock()
471            .map(|t| t.trim().to_string())
472            .unwrap_or_default();
473        if tail.is_empty() {
474            return Err(message);
475        }
476        let start = tail.chars().count().saturating_sub(500);
477        let tail: String = tail.chars().skip(start).collect();
478        return Err(format!("{}: {}", message, tail));
479    }
480    runtime.identity.port = Some(port);
481    Ok(runtime)
482}
483
484fn wait_ready(
485    runtime: &mut Runtime,
486    deadline: Instant,
487    cancel: &dyn Fn() -> bool,
488) -> Result<(), String> {
489    let mut last_error = "no response".to_string();
490    while Instant::now() < deadline {
491        if cancel() {
492            return Err("Formal AI server startup was cancelled".to_string());
493        }
494        if let Some(child) = runtime.child.as_mut() {
495            if let Ok(Some(status)) = child.try_wait() {
496                let code = status
497                    .code()
498                    .map_or_else(|| "signal".to_string(), |c| c.to_string());
499                return Err(format!("Formal AI server exited during startup ({})", code));
500            }
501        }
502        let remaining = deadline.saturating_duration_since(Instant::now());
503        match http_request(
504            &runtime.base_url,
505            "GET",
506            &format!("{}/models", API_PREFIX),
507            None,
508            remaining.min(Duration::from_secs(2)),
509            cancel,
510        ) {
511            Ok((status, _)) if (200..300).contains(&status) => return Ok(()),
512            Ok((status, _)) => last_error = format!("HTTP {}", status),
513            Err(HttpError::Timeout) => last_error = "timeout".to_string(),
514            Err(HttpError::Cancelled) => {
515                return Err("Formal AI server startup was cancelled".to_string())
516            }
517            Err(HttpError::Io(message)) => last_error = message,
518        }
519        std::thread::sleep(Duration::from_millis(100));
520    }
521    Err(format!(
522        "Formal AI server did not become ready in time ({})",
523        last_error
524    ))
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use std::cmp::Ordering;
531    use std::collections::HashMap;
532
533    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
534        let map: HashMap<String, String> = pairs
535            .iter()
536            .map(|(k, v)| (k.to_string(), v.to_string()))
537            .collect();
538        move |name| map.get(name).cloned()
539    }
540
541    fn checkout(version: &str) -> RuntimeIdentity {
542        RuntimeIdentity {
543            source: "checkout".to_string(),
544            root: Some("/fa".to_string()),
545            entry: Some(SERVER_ENTRY.to_string()),
546            version: Some(version.to_string()),
547            commit: Some("0123456789abcdef".to_string()),
548            dirty: Some(false),
549            node: Some("node".to_string()),
550            node_version: Some("v24.0.0".to_string()),
551            ..RuntimeIdentity::default()
552        }
553    }
554
555    #[test]
556    fn compares_dotted_versions() {
557        assert_eq!(
558            compare_versions("0.355.27", "0.355.27"),
559            Some(Ordering::Equal)
560        );
561        assert_eq!(
562            compare_versions("0.355.28", "0.355.27"),
563            Some(Ordering::Greater)
564        );
565        assert_eq!(compare_versions("0.355", "0.355.1"), Some(Ordering::Less));
566        assert_eq!(
567            compare_versions("1.0", "0.999.999"),
568            Some(Ordering::Greater)
569        );
570        assert_eq!(compare_versions("1.0-beta", "1.0"), None);
571        assert_eq!(compare_versions("", "1.0"), None);
572    }
573
574    #[test]
575    fn reads_the_package_version_only() {
576        let dir = tempfile::tempdir().unwrap();
577        std::fs::create_dir(dir.path().join("rust")).unwrap();
578        std::fs::write(
579            dir.path().join("rust").join("Cargo.toml"),
580            "[workspace]\nversion = \"9.9.9\"\n\n[package]\nname = \"formal-ai\"\nversion = \"0.355.27\"\n\n[dependencies]\nversion = \"1\"\n",
581        )
582        .unwrap();
583        assert_eq!(read_cargo_version(dir.path()).as_deref(), Some("0.355.27"));
584        assert_eq!(read_cargo_version(&dir.path().join("missing")), None);
585    }
586
587    #[test]
588    fn qualifies_versions_revisions_and_urls() {
589        let none = env(&[]);
590        assert!(qualify_identity(&checkout("0.355.27"), &none).is_ok());
591        let old = qualify_identity(&checkout("0.355.26"), &none).unwrap_err();
592        assert!(old.contains("needs 0.355.27 or newer"), "{}", old);
593        let pin = env(&[("START_FORMAL_AI_EXPECTED_REVISION", "0123AB")]);
594        assert!(qualify_identity(
595            &checkout("0.355.27"),
596            &env(&[("START_FORMAL_AI_EXPECTED_REVISION", "0123")])
597        )
598        .is_ok());
599        let pinned = qualify_identity(&checkout("0.355.27"), &pin).unwrap_err();
600        assert!(pinned.contains("not START_FORMAL_AI_EXPECTED_REVISION=0123AB"));
601        let mut no_node = checkout("0.355.27");
602        no_node.node_version = None;
603        assert!(qualify_identity(&no_node, &none)
604            .unwrap_err()
605            .contains("set START_FORMAL_AI_NODE"));
606        let url = RuntimeIdentity {
607            source: "url".to_string(),
608            url: Some("http://127.0.0.1:9".to_string()),
609            ..RuntimeIdentity::default()
610        };
611        assert!(qualify_identity(&url, &none)
612            .unwrap_err()
613            .contains("cannot be qualified"));
614        assert!(qualify_identity(&url, &env(&[("START_FORMAL_AI_ALLOW_UNVERIFIED", "1")])).is_ok());
615    }
616
617    #[test]
618    fn describes_identities() {
619        let mut identity = checkout("0.355.27");
620        identity.dirty = Some(true);
621        assert_eq!(
622            describe_identity(&identity),
623            "formal-ai 0.355.27 (0123456789ab+dirty, js server js/server/main.mjs, node v24.0.0)"
624        );
625        identity.commit = None;
626        assert!(describe_identity(&identity).contains("(no git commit, "));
627        let url = RuntimeIdentity {
628            source: "url".to_string(),
629            url: Some("http://h:1".to_string()),
630            ..RuntimeIdentity::default()
631        };
632        assert_eq!(
633            describe_identity(&url),
634            "formal-ai at http://h:1 (version unverified)"
635        );
636        assert_eq!(url.to_json(), json!({"source": "url", "url": "http://h:1"}));
637    }
638
639    #[test]
640    fn resolution_never_falls_back() {
641        let bounds = Bounds::default();
642        let never = || false;
643        let cases: Vec<(Vec<(&str, &str)>, &str)> = vec![
644            (vec![], "needs a Formal AI runtime and has no fallback"),
645            (
646                vec![
647                    ("START_FORMAL_AI_URL", "http://a"),
648                    ("START_FORMAL_AI_ROOT", "/b"),
649                ],
650                "Set only one of",
651            ),
652            (
653                vec![("START_FORMAL_AI_URL", "ftp://a")],
654                "must be an http:// URL",
655            ),
656            (
657                vec![("START_FORMAL_AI_URL", "https://a")],
658                "must be an http:// URL",
659            ),
660            (
661                vec![("START_FORMAL_AI_URL", "http://127.0.0.1:9")],
662                "cannot be qualified",
663            ),
664            (
665                vec![("START_FORMAL_AI_ROOT", "/nonexistent/formal-ai")],
666                "has no js/server/main.mjs",
667            ),
668        ];
669        for (pairs, message) in cases {
670            let error = start_runtime(&env(&pairs), &bounds, &never)
671                .err()
672                .expect("must fail");
673            assert!(error.contains(message), "{} !~ {}", error, message);
674        }
675        let runtime = match start_runtime(
676            &env(&[
677                ("START_FORMAL_AI_URL", "http://127.0.0.1:9/"),
678                ("START_FORMAL_AI_ALLOW_UNVERIFIED", "1"),
679            ]),
680            &bounds,
681            &never,
682        ) {
683            Ok(runtime) => runtime,
684            Err(error) => panic!("unverified URL must be allowed: {}", error),
685        };
686        assert_eq!(runtime.base_url, "http://127.0.0.1:9");
687    }
688}