x0x 0.14.7

Agent-to-agent gossip network for AI systems — no winners, no losers, just cooperation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Standalone upgrade command — works without a running daemon.
//!
//! Checks GitHub for updates, downloads the release archive, verifies
//! the ML-DSA-65 signature and SHA-256 hash, then replaces both `x0x`
//! and `x0xd` binaries in place.

use anyhow::{Context, Result};
use semver::Version;

use crate::upgrade::apply::current_binary_path;
use crate::upgrade::monitor::UpgradeMonitor;
use crate::upgrade::UpgradeError;

const REPO: &str = "saorsa-labs/x0x";

/// `x0x upgrade` — standalone upgrade (no daemon required).
pub async fn run(check_only: bool, force: bool) -> Result<()> {
    let current = crate::VERSION;
    eprintln!("x0x v{current}");
    eprintln!("Checking for updates...");

    let monitor = UpgradeMonitor::new(REPO, "x0x", current)
        .map_err(|e| anyhow::anyhow!("failed to create upgrade monitor: {e}"))?;

    // If --force, we fetch the current manifest regardless of version comparison.
    let verified = if force {
        match monitor.fetch_current_manifest().await {
            Ok(v) => v,
            Err(e) => {
                print_signature_recovery_hint(&e, current);
                return Err(anyhow::anyhow!("failed to fetch release from GitHub: {e}"));
            }
        }
    } else {
        match monitor.check_for_updates().await {
            Ok(Some(v)) => Some(v),
            Ok(None) => {
                eprintln!("Already on the latest version (v{current}).");
                return Ok(());
            }
            Err(e) => {
                print_signature_recovery_hint(&e, current);
                return Err(anyhow::anyhow!("failed to check for updates: {e}"));
            }
        }
    };

    let verified = match verified {
        Some(v) => v,
        None => {
            eprintln!("No release found on GitHub.");
            return Ok(());
        }
    };

    let new_version = &verified.manifest.version;

    if check_only {
        eprintln!("Update available: v{current} → v{new_version}");
        eprintln!("Run `x0x upgrade` to install.");
        return Ok(());
    }

    if force {
        eprintln!("Force installing v{new_version}...");
    } else {
        eprintln!("Upgrading v{current} → v{new_version}...");
    }

    // Upgrade x0xd first (the daemon binary)
    let x0x_path = current_binary_path().context("cannot resolve x0x binary path")?;
    let bin_dir = x0x_path
        .parent()
        .context("x0x binary has no parent directory")?;

    let x0xd_path = bin_dir.join(if cfg!(windows) { "x0xd.exe" } else { "x0xd" });

    // Check if x0xd exists in the same directory
    let has_x0xd = x0xd_path.exists();

    // Stop the daemon if it's running (can't replace binary while it's in use)
    let daemon_was_running = stop_daemon_if_running().await;
    if daemon_was_running {
        eprintln!("Stopped running daemon.");
    }

    if has_x0xd {
        eprintln!("Upgrading x0xd...");
        upgrade_binary("x0xd", &verified.manifest, force).await?;
        eprintln!("  x0xd → v{new_version}");
    }

    eprintln!("Upgrading x0x...");
    upgrade_binary("x0x", &verified.manifest, force).await?;
    eprintln!("  x0x  → v{new_version}");

    // Clean up stale x0x-bootstrap if present (removed in v0.8.0)
    let bootstrap_path = bin_dir.join(if cfg!(windows) {
        "x0x-bootstrap.exe"
    } else {
        "x0x-bootstrap"
    });
    if bootstrap_path.exists() {
        let _ = std::fs::remove_file(&bootstrap_path);
        eprintln!("  Removed stale x0x-bootstrap (no longer needed since v0.8.0)");
    }

    eprintln!();
    eprintln!("Upgrade complete: v{new_version}");

    // Restart the daemon if it was running before
    if daemon_was_running {
        eprintln!("Restarting daemon...");
        if let Err(e) = restart_daemon().await {
            eprintln!("  Failed to restart daemon: {e}");
            eprintln!("  Start manually: x0x start");
        } else {
            eprintln!("  Daemon restarted.");
        }
    }

    Ok(())
}

/// Upgrade a single binary using the release manifest.
///
/// Uses `AutoApplyUpgrader` without trigger_restart — the CLI handles
/// restart messaging itself.
async fn upgrade_binary(
    binary_name: &str,
    manifest: &crate::upgrade::manifest::ReleaseManifest,
    force: bool,
) -> Result<()> {
    let target_version = Version::parse(&manifest.version)
        .map_err(|e| anyhow::anyhow!("invalid version in manifest: {e}"))?;
    let current_version = Version::parse(crate::VERSION)
        .map_err(|e| anyhow::anyhow!("invalid current version: {e}"))?;

    // For --force, skip the version check by using a custom flow
    // instead of AutoApplyUpgrader (which calls trigger_restart and validate_upgrade).
    upgrade_binary_manual(
        binary_name,
        manifest,
        &current_version,
        &target_version,
        force,
    )
    .await
}

/// Manual upgrade flow that skips trigger_restart and optionally skips version check.
async fn upgrade_binary_manual(
    binary_name: &str,
    manifest: &crate::upgrade::manifest::ReleaseManifest,
    current_version: &Version,
    target_version: &Version,
    force: bool,
) -> Result<()> {
    use crate::upgrade::manifest::current_platform_target;
    use crate::upgrade::signature::{verify_bytes_signature_with_key, RELEASE_SIGNING_KEY};
    use crate::upgrade::Upgrader;
    use sha2::{Digest, Sha256};

    let platform_target = current_platform_target().context("unsupported platform for upgrade")?;

    let asset = manifest
        .matches_platform(platform_target)
        .context("no release asset for this platform")?;

    // Resolve the binary path — for "x0x" use current_exe, for others look in same dir
    let target_path = if binary_name == "x0x" {
        current_binary_path().context("cannot resolve binary path")?
    } else {
        let x0x_path = current_binary_path().context("cannot resolve x0x path")?;
        let dir = x0x_path
            .parent()
            .context("binary has no parent directory")?;
        let name = if cfg!(windows) && !binary_name.ends_with(".exe") {
            format!("{binary_name}.exe")
        } else {
            binary_name.to_string()
        };
        dir.join(name)
    };

    if !target_path.exists() {
        anyhow::bail!("{binary_name} not found at {}", target_path.display());
    }

    let upgrader = if force {
        // For --force, use version 0.0.0 so validate_upgrade always passes
        Upgrader::new(target_path.clone(), Version::new(0, 0, 0))
    } else {
        Upgrader::new(target_path.clone(), current_version.clone())
    };

    let temp_dir = upgrader
        .create_temp_dir()
        .context("failed to create temp directory")?;

    let archive_path = temp_dir.join("archive");
    let sig_path = temp_dir.join("archive.sig");

    // Download archive
    download_to_file(&asset.archive_url, &archive_path).await?;

    let archive_data = std::fs::read(&archive_path).context("failed to read downloaded archive")?;

    // Verify SHA-256
    let actual_hash: [u8; 32] = Sha256::digest(&archive_data).into();
    if actual_hash != asset.archive_sha256 {
        let _ = std::fs::remove_dir_all(&temp_dir);
        anyhow::bail!(
            "SHA-256 mismatch: expected {}, got {}",
            hex::encode(asset.archive_sha256),
            hex::encode(actual_hash)
        );
    }

    // Download and verify ML-DSA-65 signature
    download_to_file(&asset.signature_url, &sig_path).await?;
    let sig_data = std::fs::read(&sig_path).context("failed to read signature")?;

    verify_bytes_signature_with_key(&archive_data, &sig_data, RELEASE_SIGNING_KEY)
        .context("archive signature verification failed")?;

    // Extract binary
    let binary_filename = if cfg!(target_os = "windows") && !binary_name.ends_with(".exe") {
        format!("{binary_name}.exe")
    } else {
        binary_name.to_string()
    };
    let extracted_path = temp_dir.join("extracted-binary");
    crate::upgrade::apply::extract_binary_from_archive(
        &archive_path,
        &extracted_path,
        &binary_filename,
    )
    .context("failed to extract binary from archive")?;

    // Replace binary (with backup + rollback)
    upgrader
        .perform_upgrade(&extracted_path, target_version)
        .context("failed to replace binary")?;

    // Clean up temp dir
    let _ = std::fs::remove_dir_all(&temp_dir);

    Ok(())
}

/// Discover the daemon API address from the port file, if running.
fn discover_daemon_api() -> Option<String> {
    let data_dir = dirs::data_dir()?;
    let port_file = data_dir.join("x0x").join("api.port");
    std::fs::read_to_string(port_file)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Stop the daemon if it's running. Returns true if it was running.
async fn stop_daemon_if_running() -> bool {
    let addr = match discover_daemon_api() {
        Some(a) => a,
        None => return false,
    };

    let client = match reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()
    {
        Ok(c) => c,
        Err(_) => return false,
    };

    // Check if daemon is actually responding
    let health_url = format!("http://{addr}/health");
    if client.get(&health_url).send().await.is_err() {
        return false;
    }

    // Read the API token for authenticated shutdown
    let token = read_api_token();

    // Send shutdown
    let shutdown_url = format!("http://{addr}/shutdown");
    let mut req = client.post(&shutdown_url);
    if let Some(ref t) = token {
        req = req.bearer_auth(t);
    }
    let _ = req.send().await;

    // Wait for daemon to actually stop (up to 5 seconds)
    for _ in 0..10 {
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        if client.get(&health_url).send().await.is_err() {
            return true;
        }
    }

    true
}

/// Read the API token from the daemon's token file.
fn read_api_token() -> Option<String> {
    let data_dir = dirs::data_dir()?;
    let token_file = data_dir.join("x0x").join("api.token");
    std::fs::read_to_string(token_file)
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Restart the daemon after upgrade.
async fn restart_daemon() -> Result<()> {
    let x0x_path = current_binary_path().context("cannot resolve x0x binary path")?;
    let bin_dir = x0x_path
        .parent()
        .context("x0x binary has no parent directory")?;

    let x0xd_name = if cfg!(windows) { "x0xd.exe" } else { "x0xd" };
    let x0xd_path = bin_dir.join(x0xd_name);

    if !x0xd_path.exists() {
        anyhow::bail!("x0xd not found at {}", x0xd_path.display());
    }

    // Start x0xd as a background process (same as `x0x start`)
    let data_dir = dirs::data_dir().context("cannot determine data directory")?;
    let log_dir = data_dir.join("x0x");
    std::fs::create_dir_all(&log_dir).ok();
    let log_file = log_dir.join("x0xd.log");

    let log = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log_file)
        .context("failed to open log file")?;

    std::process::Command::new(&x0xd_path)
        .arg("--skip-update-check")
        .stdout(log.try_clone().context("failed to clone log handle")?)
        .stderr(log)
        .spawn()
        .context("failed to spawn x0xd")?;

    // Wait for it to become healthy (up to 15 seconds)
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .context("failed to build HTTP client")?;

    for _ in 0..15 {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        if let Some(addr) = discover_daemon_api() {
            let url = format!("http://{addr}/health");
            if client.get(&url).send().await.is_ok() {
                return Ok(());
            }
        }
    }

    anyhow::bail!("daemon started but did not become healthy within 15 seconds")
}

/// Download a URL to a local file (reuses the same logic as apply.rs).
async fn download_to_file(url: &str, destination: &std::path::Path) -> Result<()> {
    use crate::upgrade::MAX_BINARY_SIZE_BYTES;
    use futures::StreamExt;
    use std::io::Write;

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(120))
        .build()
        .context("failed to build HTTP client")?;

    let response = client
        .get(url)
        .send()
        .await
        .context("download failed")?
        .error_for_status()
        .context("download returned error status")?;

    if let Some(content_length) = response.content_length() {
        if content_length > MAX_BINARY_SIZE_BYTES {
            anyhow::bail!(
                "binary too large: {} bytes (limit: {} bytes)",
                content_length,
                MAX_BINARY_SIZE_BYTES
            );
        }
    }

    let mut file = std::fs::File::create(destination).context("failed to create download file")?;
    let mut downloaded: u64 = 0;
    let mut stream = response.bytes_stream();

    while let Some(chunk_result) = stream.next().await {
        let chunk = chunk_result.context("download stream error")?;
        downloaded += chunk.len() as u64;
        if downloaded > MAX_BINARY_SIZE_BYTES {
            drop(file);
            let _ = std::fs::remove_file(destination);
            anyhow::bail!(
                "binary too large: {} bytes (limit: {} bytes)",
                downloaded,
                MAX_BINARY_SIZE_BYTES
            );
        }
        file.write_all(&chunk).context("failed to write chunk")?;
    }

    Ok(())
}

/// If the error is a signature verification failure, print recovery instructions
/// so users on older builds with a mismatched signing key can still upgrade.
fn print_signature_recovery_hint(err: &UpgradeError, current: &str) {
    if !matches!(err, UpgradeError::ManifestSignatureInvalid) {
        return;
    }
    eprintln!();
    eprintln!("The release signature could not be verified with this binary's");
    eprintln!("embedded signing key. This typically means your x0x installation");
    eprintln!("(v{current}) predates a signing key update.");
    eprintln!();
    eprintln!("To update manually, run:");
    eprintln!();
    eprintln!("  curl -sfL https://raw.githubusercontent.com/saorsa-labs/x0x/main/scripts/install.sh | sh");
    eprintln!();
    eprintln!("Or install via cargo:");
    eprintln!();
    eprintln!("  cargo install x0x --force");
    eprintln!();
}