openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
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
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
/// `openlatch status` command handler.
///
/// Displays daemon status in a compact dashboard format (D-06 when running, D-07 when stopped).
/// All path references use `config::openlatch_dir()` per PLAT-02.
///
/// SECURITY (T-05-01-01, T-02-09): Never include API key value in status output.
/// Cloud fields show state information only — credentials are never exposed.
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::config;
use crate::error::OlError;

/// Run the `openlatch status` command.
///
/// Per D-06: compact dashboard when running.
/// Per D-07: stopped format with last-seen info when daemon is not running.
///
/// # Errors
///
/// Returns an error if config cannot be loaded.
pub fn run_status(output: &OutputConfig) -> Result<(), OlError> {
    let version = env!("CARGO_PKG_VERSION");
    let cfg = config::Config::load(None, None, false)?;

    // Check if daemon is running
    let pid_opt = lifecycle::read_pid_file();
    let is_running = pid_opt.map(lifecycle::is_process_alive).unwrap_or(false);

    if is_running {
        let pid = pid_opt.unwrap();
        show_running_status(version, cfg.port, pid, output, &cfg.cloud.api_url)?;
    } else {
        show_stopped_status(version, output)?;
    }

    Ok(())
}

/// Display compact dashboard when daemon is running (D-06).
fn show_running_status(
    version: &str,
    port: u16,
    pid: u32,
    output: &OutputConfig,
    cloud_api_url: &str,
) -> Result<(), OlError> {
    // Try to get metrics from daemon
    let health = fetch_health(port);
    let metrics = fetch_metrics(port);

    let uptime_str = health
        .as_ref()
        .and_then(|h| h.get("uptime_secs"))
        .and_then(|v| v.as_u64())
        .map(format_uptime)
        .unwrap_or_else(|| "unknown".to_string());

    let agent_str = health
        .as_ref()
        .and_then(|h| h.get("agent"))
        .and_then(|v| v.as_str())
        .unwrap_or("Claude Code")
        .to_string();

    let update_available = metrics
        .as_ref()
        .and_then(|m| m.get("update_available"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let total_events = metrics
        .as_ref()
        .and_then(|m| m.get("events_processed"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);

    // PLAT-02: Use config::openlatch_dir() for all path access
    let log_path = config::openlatch_dir().join("logs").join(format!(
        "events-{}.jsonl",
        chrono::Local::now().format("%Y-%m-%d")
    ));

    // Cloud metrics from daemon /metrics endpoint
    let cloud_status_daemon = metrics
        .as_ref()
        .and_then(|m| m.get("cloud_status"))
        .and_then(|v| v.as_str())
        .unwrap_or("not_configured")
        .to_string();

    let cloud_forwarded_count = metrics
        .as_ref()
        .and_then(|m| m.get("cloud_forwarded_count"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);

    let cloud_last_sync_secs = metrics
        .as_ref()
        .and_then(|m| m.get("cloud_last_sync_secs"))
        .and_then(|v| v.as_u64())
        .unwrap_or(0);

    // D-01: Live probe for actual connection status when running.
    // The daemon may report "not_configured" when it was started without a
    // credential store, so we probe based on credential presence rather than
    // trusting the daemon's view alone.
    // SECURITY (T-05-01-02): API key is used only in Authorization header, never logged or printed.
    let live_cloud_status = if cloud_status_daemon == "not_configured"
        && check_credential_configured() == "not_configured"
    {
        "not_configured".to_string()
    } else {
        probe_cloud_status(cloud_api_url).to_string()
    };

    let cloud_last_sync_relative = format_relative_secs(cloud_last_sync_secs);

    let supervision_line = supervision_summary_line();

    if output.format == OutputFormat::Json {
        // SECURITY (T-02-09, T-05-01-01): No API key value in output
        let json = serde_json::json!({
            "status": "running",
            "version": version,
            "port": port,
            "pid": pid,
            "uptime": uptime_str,
            "events": total_events,
            "log_path": log_path.to_string_lossy(),
            "update_available": update_available,
            "cloud_status": live_cloud_status,
            "cloud_forwarded_count": cloud_forwarded_count,
            "cloud_last_sync": cloud_last_sync_relative,
            "cloud_api_url": cloud_api_url,
            "supervision": supervision_json(),
        });
        output.print_json(&json);
    } else if !output.quiet {
        crate::cli::header::print(output, &["running", &format!("uptime {uptime_str}")]);
        eprintln!("  Port:     {port}");
        eprintln!("  Agent:    {agent_str}");
        eprintln!("  Events:   {total_events}");
        eprintln!("  Log:      {}", log_path.display());
        // UPDT-03: Show update suggestion when newer version is available
        if let Some(ref latest) = update_available {
            eprintln!("  Update:   v{latest} available (run: npx openlatch@latest)");
        }
        // Cloud section (D-02, D-04)
        if live_cloud_status == "not_configured" {
            eprintln!("  Cloud:    not configured");
            eprintln!("            Run 'openlatch auth login' to enable cloud sync");
        } else {
            eprintln!("  Cloud:    {live_cloud_status} ({cloud_api_url})");
            eprintln!(
                "  Synced:   {cloud_forwarded_count} events, last {cloud_last_sync_relative}"
            );
        }
        eprintln!("  Supervision: {supervision_line}");
    }

    Ok(())
}

/// Display stopped status with last-seen info (D-07).
fn show_stopped_status(version: &str, output: &OutputConfig) -> Result<(), OlError> {
    // Try to read last shutdown from daemon.log
    let (last_seen, last_events) = read_last_session_info();

    // D-03: When daemon is stopped, check credential presence only — no live probe.
    // SECURITY (T-05-01-01): Never expose the key value, only its presence.
    let cloud_status = check_credential_configured();

    let supervision_line = supervision_summary_line();

    if output.format == OutputFormat::Json {
        let json = serde_json::json!({
            "status": "stopped",
            "version": version,
            "last_seen": last_seen,
            "last_session_events": last_events,
            "cloud_status": cloud_status,
            "supervision": supervision_json(),
        });
        output.print_json(&json);
    } else if !output.quiet {
        crate::cli::header::print(output, &["stopped"]);
        if let Some(ref ts) = last_seen {
            let relative = format_relative_time(ts);
            eprintln!("  Last seen:  {ts} ({relative} ago)");
        } else {
            eprintln!("  Last seen:  never");
        }
        eprintln!("  Events:     {last_events} in last session");
        eprintln!("  Suggestion: Run 'openlatch start' to resume");
        // Cloud section (D-03, D-04)
        if cloud_status == "not_configured" {
            eprintln!("  Cloud:    not configured");
            eprintln!("            Run 'openlatch auth login' to enable cloud sync");
        } else {
            eprintln!("  Cloud:    configured (not running)");
        }
        eprintln!("  Supervision: {supervision_line}");
    }

    Ok(())
}

/// Build a human-readable supervision summary line.
/// Cheap: reads config.toml, does NOT probe the OS supervisor.
fn supervision_summary_line() -> String {
    use crate::supervision::{SupervisionMode, SupervisorKind};
    let cfg = match config::Config::load(None, None, false) {
        Ok(c) => c,
        Err(_) => return "unknown".to_string(),
    };
    let backend = match cfg.supervision.backend {
        SupervisorKind::Launchd => "launchd",
        SupervisorKind::Systemd => "systemd",
        SupervisorKind::TaskScheduler => "task_scheduler",
        SupervisorKind::None => "none",
    };
    match cfg.supervision.mode {
        SupervisionMode::Active => format!("{backend} (active)"),
        SupervisionMode::Deferred => {
            let reason = cfg.supervision.disabled_reason.unwrap_or_default();
            if reason.is_empty() {
                format!("{backend} (deferred)")
            } else {
                format!("{backend} (deferred — {reason})")
            }
        }
        SupervisionMode::Disabled => {
            let reason = cfg
                .supervision
                .disabled_reason
                .unwrap_or_else(|| "user_opt_out".to_string());
            format!("disabled ({reason})")
        }
    }
}

/// JSON representation of the supervision state for `status --json`.
fn supervision_json() -> serde_json::Value {
    use crate::supervision::{SupervisionMode, SupervisorKind};
    let cfg = match config::Config::load(None, None, false) {
        Ok(c) => c,
        Err(_) => return serde_json::json!(null),
    };
    let backend = match cfg.supervision.backend {
        SupervisorKind::Launchd => "launchd",
        SupervisorKind::Systemd => "systemd",
        SupervisorKind::TaskScheduler => "task_scheduler",
        SupervisorKind::None => "none",
    };
    let mode = match cfg.supervision.mode {
        SupervisionMode::Active => "active",
        SupervisionMode::Deferred => "deferred",
        SupervisionMode::Disabled => "disabled",
    };
    serde_json::json!({
        "mode": mode,
        "backend": backend,
        "disabled_reason": cfg.supervision.disabled_reason,
    })
}

/// Check if an API key credential is configured (D-03: config-only, no live probe).
///
/// Returns "configured" if any credential is present, "not_configured" otherwise.
/// SECURITY (T-05-01-01): Never exposes the key value.
fn check_credential_configured() -> &'static str {
    let store = crate::core::auth::KeyringCredentialStore::new();
    let openlatch_dir = config::openlatch_dir();
    let agent_id = config::Config::load(None, None, false)
        .ok()
        .and_then(|c| c.agent_id)
        .unwrap_or_default();
    let file_store = crate::core::auth::FileCredentialStore::new(
        openlatch_dir.join("credentials.enc"),
        agent_id,
    );

    if crate::core::auth::retrieve_credential(
        &store as &dyn crate::core::auth::CredentialStore,
        &file_store as &dyn crate::core::auth::CredentialStore,
    )
    .is_ok()
    {
        "configured"
    } else {
        "not_configured"
    }
}

/// Probe the cloud API to determine live connection status (D-01).
///
/// Performs a GET to `{api_url}/api/v1/users/me` with Bearer auth.
/// Returns: "connected" (200), "auth_error" (401/403), "disconnected" (other/timeout).
///
/// SECURITY (T-05-01-02): The API key is only placed in the Authorization header.
/// It is never stored in a variable that gets logged, printed, or returned.
/// T-05-01-03: 3-second timeout prevents status from hanging.
fn probe_cloud_status(api_url: &str) -> &'static str {
    use secrecy::ExposeSecret;

    // Retrieve credential using the fallback chain
    let store = crate::core::auth::KeyringCredentialStore::new();
    let openlatch_dir = config::openlatch_dir();
    let agent_id = config::Config::load(None, None, false)
        .ok()
        .and_then(|c| c.agent_id)
        .unwrap_or_default();
    let file_store = crate::core::auth::FileCredentialStore::new(
        openlatch_dir.join("credentials.enc"),
        agent_id,
    );

    let key = match crate::core::auth::retrieve_credential(
        &store as &dyn crate::core::auth::CredentialStore,
        &file_store as &dyn crate::core::auth::CredentialStore,
    ) {
        Ok(k) => k,
        Err(_) => return "not_configured",
    };

    // T-05-01-03: 3-second timeout to prevent status hanging when cloud is unreachable
    // WR-06: use_rustls_tls() enforces rustls-only TLS per security-constraints.md
    let client = match reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(3))
        .use_rustls_tls()
        .build()
    {
        Ok(c) => c,
        Err(_) => return "disconnected",
    };

    // WR-02: Strip trailing slash to prevent double-slash URLs.
    let base = api_url.trim_end_matches('/');
    let url = format!("{base}/api/v1/users/me");

    // SECURITY (T-05-01-02): expose_secret() only in header construction — never logged
    let result = client.get(&url).bearer_auth(key.expose_secret()).send();

    match result {
        Ok(resp) if resp.status().is_success() => "connected",
        Ok(resp)
            if resp.status() == reqwest::StatusCode::UNAUTHORIZED
                || resp.status() == reqwest::StatusCode::FORBIDDEN =>
        {
            "auth_error"
        }
        // T-05-01-04: malformed JSON or any other response → disconnected, never panic
        _ => "disconnected",
    }
}

/// Format a Unix epoch timestamp (seconds) as a relative time string.
///
/// Returns "never" when `epoch_secs` is 0.
/// Otherwise returns "{N}s ago", "{N}m ago", "{N}h ago", or "{N}d ago".
fn format_relative_secs(epoch_secs: u64) -> String {
    if epoch_secs == 0 {
        return "never".to_string();
    }

    use std::time::{SystemTime, UNIX_EPOCH};
    let now_secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let elapsed = now_secs.saturating_sub(epoch_secs);

    if elapsed < 60 {
        format!("{elapsed}s ago")
    } else if elapsed < 3600 {
        format!("{}m ago", elapsed / 60)
    } else if elapsed < 86400 {
        format!("{}h ago", elapsed / 3600)
    } else {
        format!("{}d ago", elapsed / 86400)
    }
}

/// Fetch health data from the daemon's /health endpoint.
fn fetch_health(port: u16) -> Option<serde_json::Value> {
    let url = format!("http://127.0.0.1:{port}/health");
    // WR-06: use_rustls_tls() enforces rustls-only TLS per security-constraints.md
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .use_rustls_tls()
        .build()
        .ok()?;
    let resp = client.get(&url).send().ok()?;
    if resp.status().is_success() {
        resp.json().ok()
    } else {
        None
    }
}

/// Fetch metrics from the daemon's /metrics endpoint.
fn fetch_metrics(port: u16) -> Option<serde_json::Value> {
    let url = format!("http://127.0.0.1:{port}/metrics");
    // WR-06: use_rustls_tls() enforces rustls-only TLS per security-constraints.md
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .use_rustls_tls()
        .build()
        .ok()?;
    let resp = client.get(&url).send().ok()?;
    if resp.status().is_success() {
        resp.json().ok()
    } else {
        None
    }
}

/// Read the last session info from daemon.log.
///
/// Returns (last_seen_timestamp, event_count).
fn read_last_session_info() -> (Option<String>, u64) {
    // PLAT-02: Use config::openlatch_dir() for path access
    let log_path = config::openlatch_dir().join("daemon.log");
    let Ok(content) = std::fs::read_to_string(&log_path) else {
        return (None, 0);
    };

    let mut last_seen = None;
    let mut last_events = 0u64;

    // Look for shutdown log entry with event count
    for line in content.lines().rev() {
        if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
            if entry
                .get("message")
                .and_then(|m| m.as_str())
                .map(|m| m.contains("daemon stopped") || m.contains("shutdown"))
                .unwrap_or(false)
            {
                last_seen = entry
                    .get("timestamp")
                    .and_then(|t| t.as_str())
                    .map(|s| s.to_string());
                last_events = entry.get("events").and_then(|e| e.as_u64()).unwrap_or(0);
                break;
            }
        }
    }

    (last_seen, last_events)
}

/// Format uptime in seconds to a human-readable string.
fn format_uptime(secs: u64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m {}s", secs / 60, secs % 60)
    } else {
        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
    }
}

/// Format a timestamp string as a relative time ("5 minutes").
fn format_relative_time(ts: &str) -> String {
    // Parse RFC 3339 or ISO 8601 timestamp
    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
        let now = chrono::Utc::now();
        let duration = now.signed_duration_since(dt.with_timezone(&chrono::Utc));
        let secs = duration.num_seconds();
        if secs < 60 {
            return format!("{secs} seconds");
        } else if secs < 3600 {
            return format!("{} minutes", secs / 60);
        } else if secs < 86400 {
            return format!("{} hours", secs / 3600);
        } else {
            return format!("{} days", secs / 86400);
        }
    }
    "unknown time".to_string()
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_relative_secs_zero_returns_never() {
        assert_eq!(format_relative_secs(0), "never");
    }

    #[test]
    fn test_format_relative_secs_12_returns_12s_ago() {
        // 12 seconds ago — need a timestamp 12 seconds in the past
        use std::time::{SystemTime, UNIX_EPOCH};
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let past = now_secs.saturating_sub(12);
        assert_eq!(format_relative_secs(past), "12s ago");
    }

    #[test]
    fn test_format_relative_secs_90_returns_1m_ago() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let past = now_secs.saturating_sub(90);
        assert_eq!(format_relative_secs(past), "1m ago");
    }

    #[test]
    fn test_format_relative_secs_3700_returns_1h_ago() {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let past = now_secs.saturating_sub(3700);
        assert_eq!(format_relative_secs(past), "1h ago");
    }
}