openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
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
// Windows release builds run under the `windows` subsystem so that the OS
// never allocates a console window when the binary is launched by Task
// Scheduler, a service harness, or any other non-TTY parent — the supervision
// auto-start path. Debug builds keep the default `console` subsystem so
// `cargo run` and `cargo test` output still appears in the terminal.
//
// The matching `AttachConsole(ATTACH_PARENT_PROCESS)` call in `main` restores
// stdout/stderr when the binary IS invoked from an interactive shell, so
// `openlatch status` etc. continue to print for CLI users.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]

use clap::Parser;

use openlatch_client::cli;
use openlatch_client::cli::Commands;
use openlatch_client::error::OlError;
use openlatch_client::telemetry::{self, Event};
use openlatch_client::update;

/// Outcome of the supervisor-restart-loop rollback path. Captured at
/// the very top of `main()` BEFORE telemetry / logging init, then
/// flushed out as a `update_completed` event once telemetry is alive.
/// `eprintln!` and stdlib I/O are the only outputs allowed during the
/// rollback window — `tracing::warn!` would silently no-op (no
/// subscriber yet) and `telemetry::capture_global` would no-op (handle
/// not installed yet).
enum RollbackEvent {
    /// `.bak` siblings were renamed back; sentinel cleared. Carries the
    /// `from`/`to` versions read out of the sentinel before it was
    /// cleared so the deferred telemetry event lands with real strings
    /// instead of `"(unknown)"` placeholders.
    Success { from: String, to: String },
    /// The rollback failed mid-flight. The daemon continues startup
    /// with whatever binary is on disk — typically the crash-looping
    /// new binary, which the supervisor will restart again. The reason
    /// has already been printed to stderr; nothing further to do.
    Failed,
}

/// On Windows, reattach stdio to the parent console if one exists. Called
/// before any output so interactive CLI invocations print normally under the
/// `windows` subsystem. A silent no-op when there is no parent console (the
/// Task Scheduler / launchd / systemd auto-start path).
#[cfg(windows)]
fn attach_parent_console_if_any() {
    use winapi::um::wincon::{AttachConsole, ATTACH_PARENT_PROCESS};
    // Safety: FFI call with no arguments other than a well-known constant.
    // AttachConsole returns 0 on failure (e.g. no parent console), which we
    // intentionally ignore — the daemon path has no parent to attach to.
    unsafe {
        let _ = AttachConsole(ATTACH_PARENT_PROCESS);
    }
}

#[cfg(not(windows))]
fn attach_parent_console_if_any() {}

fn main() {
    attach_parent_console_if_any();

    // `openlatch evaluate` must produce a verdict on a box holding nothing but
    // the binary — no enrolment, no credential, no daemon, no configuration file
    // (spec §3, and `ci/check-engine-purity.py` scans the command module for it).
    // Everything below this point breaks that before argv is even parsed:
    // `init_crash_report` and `init_telemetry` resolve the data directory and
    // read config, and a keychain prompt on a headless box is a hang, not an
    // error. So the subcommand is answered HERE, above all of it.
    //
    // `try_parse` rather than an argv sniff, so `openlatch --json evaluate` is
    // the same command as `openlatch evaluate` and `openlatch logs -f evaluate`
    // is not. A parse that fails — `--help`, a bad flag — falls through and is
    // reported by the normal path below, which re-parses.
    if let Ok(cli::Cli {
        command: Some(Commands::Evaluate(args)),
        ..
    }) = <cli::Cli as clap::Parser>::try_parse()
    {
        std::process::exit(openlatch_client::cli::commands::evaluate::run(&args));
    }

    // Install the process-default rustls CryptoProvider before anything can build an
    // HTTP client. `install_default()` wins at most once per process, so it has to run
    // from a single deterministic point rather than lazily from whichever supervised
    // task happens to construct its client first.
    openlatch_client::egress::init_crypto();

    // Supervisor-restart-loop rollback. Runs BEFORE logging /
    // telemetry init — no subscriber, no PostHog handle yet — so output
    // goes to stderr only. The sentinel is read first so the deferred
    // telemetry event below carries real `from`/`to` versions even
    // though `rollback_from_bak` clears the sentinel as part of its
    // cleanup.
    //
    // Gated on the daemon-foreground argv. Every supervisor template
    // (launchd / systemd / Task Scheduler) and every internal spawn
    // path invokes `daemon start --foreground`; user-facing CLI
    // commands never do. Without this gate, three quick CLI calls
    // (`update`, `status`, `--version`) inside the post-swap window
    // where sentinel + `<exe>.bak` coexist would trip the threshold
    // and silently downgrade the on-disk binary.
    let is_daemon_entry = std::env::args().any(|a| a == "--foreground");
    let rollback_event = if is_daemon_entry && update::should_rollback() {
        let (from, to) = update::read_sentinel()
            .map(|s| (s.from, s.to))
            .unwrap_or_else(|| ("(unknown)".into(), "(unknown)".into()));
        match update::rollback_from_bak() {
            Ok(()) => {
                eprintln!("[openlatch] rolled back update due to supervisor restart loop");
                Some(RollbackEvent::Success { from, to })
            }
            Err(e) => {
                eprintln!("[openlatch] rollback failed: {e}");
                Some(RollbackEvent::Failed)
            }
        }
    } else {
        None
    };

    // CLI-09: Set SIGINT handler to ensure exit code 130. Flush pending crash
    // reports before the hard exit — `process::exit` skips Drop impls, so the
    // `ClientInitGuard`'s flush-on-drop never runs.
    let _ = ctrlc::set_handler(|| {
        #[cfg(feature = "crash-report")]
        openlatch_client::crash_report::flush(std::time::Duration::from_secs(2));
        std::process::exit(130);
    });

    // Crash reporting first — captures panics in everything below, including
    // runtime construction and the detached daemon child process which
    // re-enters this main() via `daemon start --foreground`. The guard is
    // bound to `main`'s stack frame so its Drop (2s flush deadline) runs on
    // normal exit. No-op when the `crash-report` feature is disabled or
    // consent resolves to Disabled.
    #[cfg(feature = "crash-report")]
    let _crash_guard = init_crash_report();

    // Telemetry: initialise the process-global handle and install a panic hook.
    // Both are no-ops when consent is disabled or no key is baked. Phase A's
    // init never spawns a network task; Task 3 will activate the POST path.
    // Note: sentry's PanicIntegration already installed its own hook during
    // init above; our hook chains through `prev(info)` so both fire.
    init_telemetry();
    install_panic_hook();

    // Deferred rollback telemetry. Now that the global handle is
    // installed, surface the rollback outcome captured above. The
    // failed-rollback path is silent — the daemon will likely keep
    // crash-looping and the operator-visible signal lands via Sentry
    // panics rather than a successful update_completed event.
    if let Some(RollbackEvent::Success { from, to }) = rollback_event {
        telemetry::capture_global(Event::update_completed(
            &from,
            &to,
            "normal",
            "in_process",
            /* success */ false,
            /* duration_ms */ None,
            /* rolled_back */ true,
        ));
    }

    let cli_args = cli::Cli::parse();
    let output = cli::build_output_config(&cli_args);

    // No subcommand → print banner + help and exit 0. Matches `--help` UX but
    // routes through our color-aware output path so `--no-color`/non-TTY gets
    // the plain banner while TTY gets the ANSI version.
    let Some(command) = cli_args.command.as_ref() else {
        use clap::CommandFactory;
        cli::header::print_full_banner(&output);
        let _ = cli::Cli::command().print_help();
        println!();
        return;
    };

    let started_at = std::time::Instant::now();
    let (command_label, subcommand_label) = command_labels(command);

    // Tag the current process as a CLI invocation. `run_daemon_foreground`
    // overwrites `process_type` to "daemon" before any daemon work runs,
    // when this main() is reached via `daemon start --foreground`.
    #[cfg(feature = "crash-report")]
    openlatch_client::crash_report::enrich_cli_scope(command_label);

    let result = dispatch(command, &output);

    // The error decides its own status (`OlError::exit_code`) instead of every
    // failure collapsing into 1. Supervisors read this number: exit 5 is how a
    // supervised `daemon start --foreground` tells systemd "another daemon
    // already holds this machine, do not restart me", and telemetry now records
    // the status the process actually returns rather than a stand-in for it.
    // A command can succeed at running and still need to report that the
    // machine is degraded — `doctor` finding the model boundary switched off is
    // not an `OlError`, but it must not exit 0 either, or `openlatch doctor &&
    // deploy` ships with enforcement disabled. Diagnostics record that verdict
    // through `cli::report`; errors still decide their own status.
    let exit_code = result
        .as_ref()
        .err()
        .map_or_else(cli::report::pending_exit_code, OlError::exit_code);
    let duration_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
    telemetry::capture_global(Event::command_invoked(
        command_label,
        subcommand_label,
        exit_code,
        duration_ms,
    ));

    if let Err(e) = result {
        output.print_error(&e);
        std::process::exit(exit_code);
    }
    // Succeeded, but a diagnostic asked for a non-zero status. No `print_error`
    // here on purpose: the rendering already said what is wrong, in the shape
    // the operator asked for, and an "Error:" line on top of a warning report
    // would misdescribe it.
    if exit_code != 0 {
        std::process::exit(exit_code);
    }
}

/// Initialise Sentry crash reporting. Returns `None` when disabled by any
/// consent rule; subsequent `enrich_*` / `flush` calls are no-ops in that
/// state. The guard must be held for the lifetime of the program — its Drop
/// flushes pending events with a 2s deadline.
#[cfg(feature = "crash-report")]
fn init_crash_report() -> Option<sentry::ClientInitGuard> {
    let dir = openlatch_client::config::openlatch_dir();
    openlatch_client::crash_report::init(&dir)
}

/// Initialise the process-global telemetry handle. Reads the openlatch dir
/// for the consent file and agent id. Best-effort — errors are swallowed
/// because telemetry must never fail user commands (invariant I10-adjacent).
fn init_telemetry() {
    let dir = openlatch_client::config::openlatch_dir();
    // Pre-init builds have no agent_id yet; "agt_unknown" is the documented
    // placeholder for the very first command before `openlatch init` runs.
    let agent_id =
        openlatch_client::config::sniff_agent_id(&dir).unwrap_or_else(|| "agt_unknown".to_string());

    // Task 3: probe for a non-empty PostHog key (build-time baked or runtime
    // override). Empty key → no-op handle, preserves I1.
    let baked_key_present = openlatch_client::telemetry::network::key_is_present();
    // Telemetry POSTs leave the host like everything else, so they take the configured
    // proxy route. A config we cannot read is not worth failing a command over — the
    // batch client falls back to direct, exactly as it behaved before there was a route.
    let egress = openlatch_client::config::Config::load(None, None, false)
        .map(|c| c.egress)
        .unwrap_or_else(|_| openlatch_client::egress::EgressConfig::direct());
    let handle = telemetry::init(&dir, agent_id, false, baked_key_present, egress);
    let _ = telemetry::install_global(handle);
}

/// Convert a panic hook into a `daemon_crashed` event. Captures only the
/// `file:line` of the panic location — never the message or interpolated
/// values (§5.3 of the brainstorm).
fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let location = info
            .location()
            .map(|l| format!("{}:{}", l.file(), l.line()))
            .unwrap_or_else(|| "unknown".to_string());
        telemetry::capture_global(Event::daemon_crashed(&location, 0));
        prev(info);
    }));
}

/// Map a parsed CLI command to a stable `(command, subcommand)` pair for the
/// `command_invoked` event. Names are taken from the clap subcommand grammar
/// — never from user-supplied flag values.
fn command_labels(cmd: &Commands) -> (&'static str, Option<&'static str>) {
    match cmd {
        Commands::Init(_) => ("init", None),
        Commands::Status => ("status", None),
        Commands::Start(_) => ("start", None),
        Commands::Stop => ("stop", None),
        Commands::Restart => ("restart", None),
        Commands::Logs(_) => ("logs", None),
        Commands::Doctor(_) => ("doctor", None),
        Commands::Uninstall(_) => ("uninstall", None),
        Commands::Docs => ("docs", None),
        Commands::Hooks { cmd } => (
            "hooks",
            Some(match cmd {
                cli::HooksCommands::Install(_) => "install",
                cli::HooksCommands::Uninstall(_) => "uninstall",
                cli::HooksCommands::Status => "status",
            }),
        ),
        Commands::Daemon { cmd } => (
            "daemon",
            Some(match cmd {
                cli::DaemonCommands::Start(_) => "start",
                cli::DaemonCommands::Stop => "stop",
                cli::DaemonCommands::Restart => "restart",
            }),
        ),
        Commands::Auth { cmd } => (
            "auth",
            Some(match cmd {
                cli::AuthCommands::Login(_) => "login",
                cli::AuthCommands::Logout => "logout",
                cli::AuthCommands::Status => "status",
            }),
        ),
        Commands::Telemetry { cmd } => (
            "telemetry",
            Some(match cmd {
                cli::TelemetryCommands::Status => "status",
                cli::TelemetryCommands::Enable => "enable",
                cli::TelemetryCommands::Disable => "disable",
                cli::TelemetryCommands::Purge => "purge",
                cli::TelemetryCommands::Debug => "debug",
            }),
        ),
        Commands::Supervision { cmd } => (
            "supervision",
            Some(match cmd {
                cli::SupervisionCommands::Install => "install",
                cli::SupervisionCommands::Uninstall => "uninstall",
                cli::SupervisionCommands::Status => "status",
                cli::SupervisionCommands::Enable => "enable",
                cli::SupervisionCommands::Disable => "disable",
            }),
        ),
        Commands::Update(_) => ("update", None),
        // Unreachable: `main` answers `evaluate` above every init, telemetry
        // included. Present because the match is exhaustive, and because a
        // stateless command is one that reports nothing about itself anywhere.
        Commands::Evaluate(_) => ("evaluate", None),
        Commands::Inventory { cmd } => (
            "inventory",
            Some(match cmd {
                cli::InventoryCommands::List(_) => "list",
                cli::InventoryCommands::Log(_) => "log",
                cli::InventoryCommands::Rescan(_) => "rescan",
                cli::InventoryCommands::Status(_) => "status",
                cli::InventoryCommands::Inspect(_) => "inspect",
                cli::InventoryCommands::Projects(_) => "projects",
                cli::InventoryCommands::Ack(_) => "ack",
            }),
        ),
        // Hidden Phase 1 spike — emit a telemetry label so we can spot
        // accidental invocations in aggregate even though the subcommand
        // is not user-facing.
        Commands::SpikeUpdate(_) => ("__spike-update", None),
        Commands::Boundary { cmd } => (
            "boundary",
            Some(match cmd {
                cli::BoundaryCommands::Status => "status",
                cli::BoundaryCommands::Enable(_) => "enable",
                cli::BoundaryCommands::Disable(_) => "disable",
                cli::BoundaryCommands::Explain { .. } => "explain",
            }),
        ),
        Commands::Proxy { cmd } => (
            "proxy",
            Some(match cmd {
                cli::ProxyCommands::Status => "status",
                cli::ProxyCommands::Discover(_) => "discover",
                cli::ProxyCommands::Set(_) => "set",
                cli::ProxyCommands::Clear => "clear",
                cli::ProxyCommands::Test(_) => "test",
            }),
        ),
        Commands::Bench { cmd } => (
            "bench",
            Some(match cmd {
                cli::BenchCommands::PanicIsolation(_) => "panic-isolation",
                cli::BenchCommands::PortStability => "port-stability",
                cli::BenchCommands::ExportFixtures => "export-fixtures",
                cli::BenchCommands::TransformEgress => "transform-egress",
                cli::BenchCommands::Replay(_) => "replay",
                cli::BenchCommands::CacheBaseline(_) => "cache-baseline",
                cli::BenchCommands::Compare(_) => "compare",
                cli::BenchCommands::Attribution(_) => "attribution",
            }),
        ),
    }
}

/// Dispatch CLI commands to their handlers.
///
/// Returns `Err(OlError)` on failure; main() prints the error and exits 1.
fn dispatch(
    command: &Commands,
    output: &openlatch_client::cli::output::OutputConfig,
) -> Result<(), openlatch_client::error::OlError> {
    match command {
        Commands::Init(args) => openlatch_client::cli::commands::init::run_init(args, output),
        Commands::Status => openlatch_client::cli::commands::status::run_status(output),
        Commands::Start(args) => {
            openlatch_client::cli::commands::lifecycle::run_start(args, output)
        }
        Commands::Stop => openlatch_client::cli::commands::lifecycle::run_stop(output),
        Commands::Restart => openlatch_client::cli::commands::lifecycle::run_restart(output),
        Commands::Logs(args) => openlatch_client::cli::commands::logs::run_logs(args, output),
        Commands::Doctor(args) => openlatch_client::cli::commands::doctor::run_doctor(args, output),
        Commands::Uninstall(args) => {
            openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
        }
        Commands::Docs => openlatch_client::cli::commands::docs::run_docs(output),
        Commands::Hooks { cmd } => match cmd {
            cli::HooksCommands::Install(args) => {
                openlatch_client::cli::commands::init::run_init(args, output)
            }
            cli::HooksCommands::Uninstall(args) => {
                openlatch_client::cli::commands::uninstall::run_uninstall(args, output)
            }
            // Not an alias of `status`. It was one, so `openlatch hooks
            // status` printed the whole dashboard and not one hook-specific
            // fact — the twelve entries, the binary each resolves to, the
            // token, the port.
            cli::HooksCommands::Status => openlatch_client::cli::commands::doctor::run_section(
                openlatch_client::cli::report::Section::Hooks,
                output,
            ),
        },
        Commands::Daemon { cmd } => match cmd {
            cli::DaemonCommands::Start(args) => {
                openlatch_client::cli::commands::lifecycle::run_start(args, output)
            }
            cli::DaemonCommands::Stop => {
                openlatch_client::cli::commands::lifecycle::run_stop(output)
            }
            cli::DaemonCommands::Restart => {
                openlatch_client::cli::commands::lifecycle::run_restart(output)
            }
        },
        Commands::Auth { cmd } => match cmd {
            cli::AuthCommands::Login(args) => {
                openlatch_client::cli::commands::auth::run_login(args, output)
            }
            cli::AuthCommands::Logout => openlatch_client::cli::commands::auth::run_logout(output),
            cli::AuthCommands::Status => openlatch_client::cli::commands::auth::run_status(output),
        },
        Commands::Telemetry { cmd } => openlatch_client::cli::commands::telemetry::run(cmd, output),
        Commands::Supervision { cmd } => {
            openlatch_client::cli::commands::supervision::run(cmd, output)
        }
        // The update command surfaces multi-valued exit codes (5 for
        // cargo-install refusal, 6 for daemon-unreachable, 1 for
        // failure, 0 for success/idempotent) that the OL-XXXX dispatch
        // pipeline cannot express, so it `process::exit`s directly.
        Commands::Update(args) => {
            let code = openlatch_client::cli::commands::update::run(args, output);
            // A successful swap that left a stale daemon serving is not a clean
            // 0: the binary moved, the running process did not, and the fix the
            // user just installed is not the code enforcing anything.
            let code = if code == 0 {
                openlatch_client::cli::report::pending_exit_code()
            } else {
                code
            };
            std::process::exit(code);
        }
        Commands::Inventory { cmd } => {
            openlatch_client::cli::commands::inventory::run_inventory(cmd, output)
        }
        // Hidden Phase 1 spike — bypasses the OL-XXXX error pipeline since
        // it is maintainer-only and has its own stderr diagnostics. The
        // process::exit means dispatch never returns past this branch.
        Commands::SpikeUpdate(args) => {
            let success = openlatch_client::cli::commands::spike_update::run(args);
            std::process::exit(if success { 0 } else { 1 });
        }
        // Unreachable in practice — `main` answers this one before any of the
        // process init that dispatch runs below. Kept so the command is complete
        // from the CLI tree alone, and it exits directly for the same reason
        // `update` does: 0, 1 and 2 are a wire contract, not an `OlError`.
        Commands::Evaluate(args) => {
            std::process::exit(openlatch_client::cli::commands::evaluate::run(args));
        }
        Commands::Boundary { cmd } => openlatch_client::cli::commands::boundary::run(cmd, output),
        Commands::Proxy { cmd } => openlatch_client::cli::commands::proxy::run(cmd, output),
        // Boundary verification benches build their OWN tokio runtime (this
        // dispatch is synchronous — mirrors lifecycle.rs).
        Commands::Bench { cmd } => match cmd {
            cli::BenchCommands::PanicIsolation(args) => {
                openlatch_client::boundary::bench::run_panic_isolation(&args.inject)
            }
            cli::BenchCommands::PortStability => {
                openlatch_client::boundary::bench::run_port_stability()
            }
            cli::BenchCommands::ExportFixtures => {
                openlatch_client::boundary::bench::run_export_fixtures()
            }
            cli::BenchCommands::TransformEgress => {
                openlatch_client::boundary::bench::run_transform_egress()
            }
            cli::BenchCommands::Replay(args) => {
                openlatch_client::boundary::bench::run_replay(&args.rule, args.runs)
            }
            cli::BenchCommands::CacheBaseline(args) => {
                let out = args.out.clone().or_else(|| args.out_positional.clone());
                openlatch_client::boundary::bench::run_cache_baseline(
                    args.turns,
                    args.with_mcp,
                    args.direct,
                    args.uncached_prefix,
                    out.as_deref(),
                )
            }
            cli::BenchCommands::Compare(args) => {
                openlatch_client::boundary::bench::run_compare(&args.a, &args.b)
            }
            cli::BenchCommands::Attribution(args) => {
                openlatch_client::boundary::bench::run_attribution(args.sessions, args.turns)
            }
        },
    }
}