fix(mission_runtime): mint pairing code via admin endpoint, not log scrape
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 30s
ci / rust (push) Failing after 41s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Fresh gateways sometimes boot claim-ing already paired (no
pairing_code in the log banner), which broke the log-scrape approach.
Instead, docker exec into the container and hit the localhost
/admin/paircode/new endpoint that always mints a fresh one-time
code and returns JSON we can parse.
This commit is contained in:
Omar Sobh
2026-07-22 13:50:57 -07:00
parent 5f4407e889
commit 54bba1e113
+52 -38
View File
@@ -24,12 +24,12 @@
//! carry the current binding (both null when torn down or never //! carry the current binding (both null when torn down or never
//! provisioned). //! provisioned).
use bollard::exec::{CreateExecOptions, StartExecResults};
use bollard::models::{ use bollard::models::{
ContainerCreateBody, EndpointSettings, HostConfig, Mount, MountTypeEnum, NetworkConnectRequest, ContainerCreateBody, EndpointSettings, HostConfig, Mount, MountTypeEnum, NetworkConnectRequest,
}; };
use bollard::query_parameters::{ use bollard::query_parameters::{
CreateContainerOptions, InspectContainerOptions, LogsOptions, RemoveContainerOptions, CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
StartContainerOptions,
}; };
use bollard::Docker; use bollard::Docker;
use futures::StreamExt; use futures::StreamExt;
@@ -248,15 +248,15 @@ impl MissionRuntimeProvisioner {
}) })
} }
/// Poll container logs for up to ~10s waiting for the ZeroClaw /// Poll the daemon's localhost admin endpoint until it responds
/// pairing banner. Returns None on timeout — callers keep any /// with a fresh pairing code. Boot takes ~1-2s; deadline is 15s.
/// previously-persisted code, or the topology run fails and the /// Returns None on timeout so callers see a clear paired=false
/// operator sees a clear error surface. /// signal in the mission binding log.
async fn wait_for_pairing_code(&self, name: &str) -> Option<String> { async fn wait_for_pairing_code(&self, name: &str) -> Option<String> {
let deadline = std::time::Duration::from_secs(10); let deadline = std::time::Duration::from_secs(15);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
while start.elapsed() < deadline { while start.elapsed() < deadline {
if let Some(code) = self.scrape_pairing_code(name).await { if let Some(code) = self.mint_pairing_code(name).await {
return Some(code); return Some(code);
} }
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
@@ -264,47 +264,61 @@ impl MissionRuntimeProvisioner {
None None
} }
/// One-shot scrape of `docker logs <name>` for `X-Pairing-Code: NNNNNN`. /// `docker exec` into the container and hit the local admin
async fn scrape_pairing_code(&self, name: &str) -> Option<String> { /// endpoint that mints a fresh pairing code. This works whether
let mut stream = self.docker.logs( /// the daemon booted "already paired" (no code in the log) or
/// "pairing required" (code in the log) — both surfaces mint on
/// demand.
async fn mint_pairing_code(&self, name: &str) -> Option<String> {
let exec = self
.docker
.create_exec(
name, name,
Some(LogsOptions { CreateExecOptions {
stdout: true, cmd: Some(
stderr: true, [
tail: "200".to_string(), "curl",
"-fs",
"-X",
"POST",
"http://127.0.0.1:42617/admin/paircode/new",
]
.iter()
.map(|s| s.to_string())
.collect(),
),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default() ..Default::default()
}), },
); )
.await
.ok()?;
let started = self.docker.start_exec(&exec.id, None).await.ok()?;
let StartExecResults::Attached { mut output, .. } = started else {
return None;
};
let mut buf = String::new(); let mut buf = String::new();
while let Some(chunk) = stream.next().await { while let Some(chunk) = output.next().await {
if let Ok(c) = chunk { if let Ok(c) = chunk {
buf.push_str(&String::from_utf8_lossy(&c.into_bytes())); buf.push_str(&c.to_string());
if buf.len() > 64_000 { if buf.len() > 8_000 {
break; break;
} }
} }
} }
extract_pairing_code(&buf) extract_pairing_code_from_json(&buf)
} }
} }
/// Parse a ZeroClaw daemon boot banner and pull out the pairing code /// Parse the JSON `{ "pairing_code": "NNNNNN", ... }` body from the
/// from a line like `Send: POST /pair with header X-Pairing-Code: 273200`. /// admin/paircode/new endpoint.
fn extract_pairing_code(logs: &str) -> Option<String> { fn extract_pairing_code_from_json(body: &str) -> Option<String> {
for line in logs.lines() { let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
if let Some(idx) = line.find("X-Pairing-Code:") { v.get("pairing_code")
let rest = &line[idx + "X-Pairing-Code:".len()..]; .and_then(|x| x.as_str())
let code: String = rest .filter(|s| !s.is_empty())
.chars() .map(String::from)
.skip_while(|c| c.is_whitespace())
.take_while(|c| c.is_ascii_digit())
.collect();
if !code.is_empty() {
return Some(code);
}
}
}
None
} }
impl MissionRuntimeProvisioner { impl MissionRuntimeProvisioner {