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
/// Error framework for OpenLatch client using the OL-XXXX code format.
///
/// All user-facing errors carry a structured code, message, optional suggestion,
/// and optional docs URL. The Display format follows the D-06/D-07 convention:
///
/// ```text
/// Error: {message} (OL-XXXX)
///
///   Suggestion: {actionable text}
///   Docs: {url}
/// ```
///
/// Suggestion and Docs lines are omitted when the respective field is `None`.
use std::fmt;

/// A structured, user-facing error with an OL-XXXX code.
///
/// # Display format (D-06/D-07)
///
/// ```text
/// Error: {message} (OL-XXXX)
///
///   Suggestion: {actionable text}
///   Docs: {url}
/// ```
#[derive(Debug, Clone)]
pub struct OlError {
    /// The OL-XXXX error code (e.g. "OL-1001").
    pub code: &'static str,
    /// Human-readable, actionable error description.
    pub message: String,
    /// Optional suggestion for how to fix the error.
    pub suggestion: Option<String>,
    /// Optional link to documentation for this error code.
    pub docs_url: Option<String>,
}

impl OlError {
    /// Create a new error with the given code and message.
    pub fn new(code: &'static str, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
            suggestion: None,
            docs_url: None,
        }
    }

    /// Attach a suggestion to this error.
    pub fn with_suggestion(mut self, s: impl Into<String>) -> Self {
        self.suggestion = Some(s.into());
        self
    }

    /// Attach a docs URL to this error.
    pub fn with_docs(mut self, url: impl Into<String>) -> Self {
        self.docs_url = Some(url.into());
        self
    }

    /// Build a "bug report" error pre-filled with a GitHub issue URL.
    ///
    /// Use this for unexpected internal errors that indicate a bug in openlatch.
    pub fn bug_report(message: impl Into<String>) -> Self {
        let msg = message.into();
        let url = format!(
            "https://github.com/OpenLatch/openlatch-client/issues/new?title={}&body={}",
            percent_encode(&msg),
            percent_encode("Version: [auto]\nOS: [auto]\n\nDescription:\n"),
        );
        Self {
            code: "OL-9999",
            message: msg,
            suggestion: Some("This is a bug. Please report it.".into()),
            docs_url: Some(url),
        }
    }
}

impl fmt::Display for OlError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Error: {} ({})", self.message, self.code)?;

        let has_suggestion = self.suggestion.is_some();
        let has_docs = self.docs_url.is_some();

        if has_suggestion || has_docs {
            writeln!(f)?;
            writeln!(f)?;
            if let Some(ref s) = self.suggestion {
                writeln!(f, "  Suggestion: {s}")?;
            }
            if let Some(ref url) = self.docs_url {
                write!(f, "  Docs: {url}")?;
            }
        }

        Ok(())
    }
}

impl std::error::Error for OlError {}

/// Minimal percent-encoding for URL query parameters.
///
/// Only encodes characters that break URL structure: space, newline, carriage return,
/// ampersand, equals sign, and the hash character. This avoids pulling in a
/// full URL-encoding dependency for a single use in bug_report().
fn percent_encode(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    for c in input.chars() {
        match c {
            ' ' => out.push_str("%20"),
            '\n' => out.push_str("%0A"),
            '\r' => out.push_str("%0D"),
            '&' => out.push_str("%26"),
            '=' => out.push_str("%3D"),
            '#' => out.push_str("%23"),
            other => out.push(other),
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Envelope errors (OL-1000–1099)
// ---------------------------------------------------------------------------

// OL-1001 (ERR_UNKNOWN_AGENT) was retired with the CloudEvents v1.0.2
// migration — `source` and `type` are open strings surfaced through the
// tagged-enum lens (src/core/envelope/known_types.rs), so unknown values
// are a valid runtime path, not an error. The code is intentionally left
// vacant so it is never reused with different semantics. See
// .brainstorms/2026-04-16-forward-compatible-wire-enums.md §5.7.
/// Event body exceeds the 1 MB size limit.
pub const ERR_EVENT_TOO_LARGE: &str = "OL-1002";
/// Event was deduplicated within the TTL window (informational).
pub const ERR_EVENT_DEDUPED: &str = "OL-1003";

// ---------------------------------------------------------------------------
// Privacy filter errors (OL-1100–1199)
// ---------------------------------------------------------------------------

/// A custom regex pattern in config.toml failed to compile.
pub const ERR_INVALID_REGEX: &str = "OL-1100";

// ---------------------------------------------------------------------------
// Cloud forwarding errors (OL-1200–1299)
// ---------------------------------------------------------------------------

/// Cloud API endpoint is unreachable (network error or DNS failure).
pub const ERR_CLOUD_UNREACHABLE: &str = "OL-1200";
/// Cloud returned 401/403 — API key is invalid or revoked.
pub const ERR_CLOUD_AUTH_FAILED: &str = "OL-1201";
/// Cloud returned 429 — rate limit exceeded; respect Retry-After header.
pub const ERR_CLOUD_RATE_LIMITED: &str = "OL-1202";

// ---------------------------------------------------------------------------
// Config errors (OL-1300–1399)
// ---------------------------------------------------------------------------

/// The configuration file contains an invalid value.
pub const ERR_INVALID_CONFIG: &str = "OL-1300";
/// A required configuration field is absent and has no default.
pub const ERR_MISSING_CONFIG_FIELD: &str = "OL-1301";

// ---------------------------------------------------------------------------
// Hooks errors (OL-1400–OL-1499)
// ---------------------------------------------------------------------------

/// No supported AI agent was detected on this machine.
pub const ERR_HOOK_AGENT_NOT_FOUND: &str = "OL-1400";
/// Cannot read or write the agent's settings.json (permissions, I/O error).
pub const ERR_HOOK_WRITE_FAILED: &str = "OL-1401";
/// The settings.json file contains malformed JSONC that cannot be parsed.
pub const ERR_HOOK_MALFORMED_JSONC: &str = "OL-1402";
/// Existing non-OpenLatch hooks detected in settings.json (warning, non-blocking).
pub const ERR_HOOK_CONFLICT: &str = "OL-1403";

// ---------------------------------------------------------------------------
// Daemon errors (OL-1500–1599)
// ---------------------------------------------------------------------------

/// The selected port is already in use by another process.
pub const ERR_PORT_IN_USE: &str = "OL-1500";
/// A daemon instance is already running on this machine.
pub const ERR_ALREADY_RUNNING: &str = "OL-1501";
/// Daemon process started but health check failed within timeout.
pub const ERR_DAEMON_START_FAILED: &str = "OL-1502";
/// A newer version of openlatch is available (warning, non-blocking).
pub const ERR_VERSION_OUTDATED: &str = "OL-1503";

// ---------------------------------------------------------------------------
// Auth / credential errors (OL-1600–1699)
// ---------------------------------------------------------------------------

/// No credentials found in keychain, env var, or encrypted file.
pub const ERR_NO_CREDENTIALS: &str = "OL-1600";
/// API key / token has expired or been revoked.
pub const ERR_TOKEN_EXPIRED: &str = "OL-1601";
/// OS keychain service is unavailable (e.g., no Secret Service on headless Linux).
pub const ERR_KEYCHAIN_UNAVAILABLE: &str = "OL-1602";
/// OS keychain denied access (permission error).
pub const ERR_KEYCHAIN_PERMISSION: &str = "OL-1603";
/// Encrypted file fallback failed (decrypt error, missing file, or corrupt data).
pub const ERR_FILE_FALLBACK_ERROR: &str = "OL-1604";
/// Auth login flow timed out after 5 minutes waiting for browser callback.
pub const ERR_AUTH_TIMEOUT: &str = "OL-1605";
/// Auth login flow failed (callback server error, truncated request, browser launch error).
pub const ERR_AUTH_FLOW_FAILED: &str = "OL-1606";
/// Server-side API key revocation failed during logout (warning only — local cleanup continues).
pub const ERR_AUTH_REVOCATION_FAILED: &str = "OL-1607";

/// Returns a platform-specific suggestion for keychain errors (D-23).
///
/// Provides actionable guidance tailored to the current operating system,
/// including an `OPENLATCH_API_KEY` env var fallback in all cases.
pub fn keychain_suggestion() -> String {
    if cfg!(target_os = "linux") {
        "Install and start gnome-keyring or KWallet, or set OPENLATCH_API_KEY env var as fallback."
            .into()
    } else if cfg!(target_os = "windows") {
        "Check Windows Credential Manager in Control Panel, or set OPENLATCH_API_KEY env var."
            .into()
    } else if cfg!(target_os = "macos") {
        "Check Keychain Access.app permissions, or set OPENLATCH_API_KEY env var.".into()
    } else {
        "Set OPENLATCH_API_KEY env var as an alternative to OS keychain.".into()
    }
}

// ---------------------------------------------------------------------------
// Telemetry errors (OL-1700–1799)
// ---------------------------------------------------------------------------

/// The telemetry.json consent file contains invalid JSON and cannot be parsed.
pub const ERR_TELEMETRY_CONFIG_CORRUPT: &str = "OL-1700";
/// Failed to write the telemetry.json consent file (permissions or I/O error).
pub const ERR_TELEMETRY_WRITE_FAILED: &str = "OL-1701";
/// A telemetry batch POST failed (informational — events dropped, no retry).
pub const ERR_TELEMETRY_POST_FAILED: &str = "OL-1702";
/// Telemetry subsystem failed to initialise (baked key missing, channel error).
pub const ERR_TELEMETRY_INIT_FAILED: &str = "OL-1703";

// ---------------------------------------------------------------------------
// Doctor (--fix / --restore / --rescue) errors (OL-1800–1899)
// ---------------------------------------------------------------------------

/// `doctor --fix` journal file is present but cannot be parsed (--restore can't drive).
pub const ERR_DOCTOR_JOURNAL_CORRUPT: &str = "OL-1800";
/// `doctor --restore` was invoked with no prior `--fix` journal on disk.
pub const ERR_DOCTOR_RESTORE_NO_JOURNAL: &str = "OL-1801";
/// `doctor --rescue` could not write the archive (disk full, permissions, etc.).
pub const ERR_DOCTOR_RESCUE_WRITE_FAILED: &str = "OL-1802";
/// `doctor --rescue` produced an archive but one or more collectors errored.
pub const ERR_DOCTOR_RESCUE_PARTIAL: &str = "OL-1803";

// ---------------------------------------------------------------------------
// Tamper-evidence errors (OL-1900–1999)
// ---------------------------------------------------------------------------

/// HMAC key unavailable — both OS keyring and file fallback failed.
pub const ERR_HMAC_KEY_UNAVAILABLE: &str = "OL-1900";
/// The hook-state.json file exists but cannot be parsed (invalid JSON or unknown schema_version).
pub const ERR_STATE_FILE_CORRUPT: &str = "OL-1901";
/// Atomic write to hook-state.json failed after settings.json was written.
pub const ERR_STATE_FILE_WRITE_FAILED: &str = "OL-1902";
/// The `_openlatch.v` field carries a version we don't understand.
pub const ERR_MARKER_SCHEMA_UNSUPPORTED: &str = "OL-1903";
/// JCS canonicalization rejected the hook entry (internal error; should not happen).
pub const ERR_CANONICALIZATION_FAILED: &str = "OL-1904";
/// A legacy `_openlatch: true` boolean marker was found and will be upgraded (info-level).
pub const ERR_LEGACY_MARKER_DETECTED: &str = "OL-1905";

// ---------------------------------------------------------------------------
// Bug report sentinel
// ---------------------------------------------------------------------------

/// Code assigned to all internal/unexpected errors routed through bug_report().
pub const ERR_BUG: &str = "OL-9999";

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

    #[test]
    fn test_ol_error_display_full_format() {
        // OlError Display output matches D-06/D-07 format. Uses a stable
        // error code unaffected by the CloudEvents migration (OL-1002 is
        // retained; OL-1001 was retired when the wire format opened up).
        let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
            .with_suggestion("Split the payload into smaller events")
            .with_docs("https://docs.openlatch.ai/errors/OL-1002");

        let output = format!("{err}");
        assert!(
            output.starts_with("Error: Event body exceeds 1 MB limit (OL-1002)"),
            "Expected error header, got: {output}"
        );
        assert!(
            output.contains("Suggestion: Split the payload"),
            "Missing suggestion"
        );
        assert!(
            output.contains("Docs: https://docs.openlatch.ai"),
            "Missing docs URL"
        );
    }

    #[test]
    fn test_ol_error_display_no_suggestion() {
        // Test 2: OlError without suggestion omits the suggestion line
        let err = OlError::new(ERR_EVENT_TOO_LARGE, "Event body exceeds 1 MB limit")
            .with_docs("https://docs.openlatch.ai/errors/OL-1002");

        let output = format!("{err}");
        assert!(
            !output.contains("Suggestion:"),
            "Should not contain suggestion line: {output}"
        );
        assert!(
            output.contains("Docs:"),
            "Should still contain docs line: {output}"
        );
    }

    #[test]
    fn test_ol_error_display_no_docs_url() {
        // Test 3: OlError without docs_url omits the docs line
        let err = OlError::new(ERR_INVALID_REGEX, "Invalid regex pattern")
            .with_suggestion("Fix the regex in your config");

        let output = format!("{err}");
        assert!(
            !output.contains("Docs:"),
            "Should not contain docs line: {output}"
        );
        assert!(
            output.contains("Suggestion:"),
            "Should still contain suggestion: {output}"
        );
    }

    #[test]
    fn test_ol_error_display_no_optional_fields() {
        // Test 3 (extended): OlError with neither suggestion nor docs
        let err = OlError::new(ERR_PORT_IN_USE, "Port 7443 is already in use");

        let output = format!("{err}");
        assert_eq!(output, "Error: Port 7443 is already in use (OL-1500)");
    }

    #[test]
    fn test_error_code_constants_exist() {
        // Error code constants exist for each subsystem range. OL-1001
        // (ERR_UNKNOWN_AGENT) was retired with the CloudEvents migration.
        assert_eq!(ERR_EVENT_TOO_LARGE, "OL-1002");
        assert_eq!(ERR_INVALID_REGEX, "OL-1100");
        assert_eq!(ERR_INVALID_CONFIG, "OL-1300");
        assert_eq!(ERR_MISSING_CONFIG_FIELD, "OL-1301");
        assert_eq!(ERR_EVENT_DEDUPED, "OL-1003");
        assert_eq!(ERR_HOOK_CONFLICT, "OL-1403");
        assert_eq!(ERR_PORT_IN_USE, "OL-1500");
        assert_eq!(ERR_ALREADY_RUNNING, "OL-1501");
        assert_eq!(ERR_DAEMON_START_FAILED, "OL-1502");
        assert_eq!(ERR_VERSION_OUTDATED, "OL-1503");
        // Cloud forwarding errors
        assert_eq!(ERR_CLOUD_UNREACHABLE, "OL-1200");
        assert_eq!(ERR_CLOUD_AUTH_FAILED, "OL-1201");
        assert_eq!(ERR_CLOUD_RATE_LIMITED, "OL-1202");
        // Auth / credential errors
        assert_eq!(ERR_NO_CREDENTIALS, "OL-1600");
        assert_eq!(ERR_TOKEN_EXPIRED, "OL-1601");
        assert_eq!(ERR_KEYCHAIN_UNAVAILABLE, "OL-1602");
        assert_eq!(ERR_KEYCHAIN_PERMISSION, "OL-1603");
        assert_eq!(ERR_FILE_FALLBACK_ERROR, "OL-1604");
        // Tamper-evidence errors
        assert_eq!(ERR_HMAC_KEY_UNAVAILABLE, "OL-1900");
        assert_eq!(ERR_STATE_FILE_CORRUPT, "OL-1901");
        assert_eq!(ERR_STATE_FILE_WRITE_FAILED, "OL-1902");
        assert_eq!(ERR_MARKER_SCHEMA_UNSUPPORTED, "OL-1903");
        assert_eq!(ERR_CANONICALIZATION_FAILED, "OL-1904");
        assert_eq!(ERR_LEGACY_MARKER_DETECTED, "OL-1905");
    }

    #[test]
    fn test_keychain_suggestion_returns_non_empty_string() {
        // keychain_suggestion() must return a non-empty, platform-specific string
        let suggestion = keychain_suggestion();
        assert!(
            !suggestion.is_empty(),
            "keychain_suggestion must not be empty"
        );
        // On Windows it should mention Credential Manager or env var
        #[cfg(target_os = "windows")]
        assert!(
            suggestion.contains("Credential Manager") || suggestion.contains("OPENLATCH_API_KEY"),
            "Windows suggestion must mention Credential Manager or env var: {suggestion}"
        );
        // On macOS it should mention Keychain Access
        #[cfg(target_os = "macos")]
        assert!(
            suggestion.contains("Keychain") || suggestion.contains("OPENLATCH_API_KEY"),
            "macOS suggestion must mention Keychain: {suggestion}"
        );
        // On Linux it should mention gnome-keyring or Secret Service
        #[cfg(target_os = "linux")]
        assert!(
            suggestion.contains("gnome-keyring") || suggestion.contains("OPENLATCH_API_KEY"),
            "Linux suggestion must mention gnome-keyring or env var: {suggestion}"
        );
    }

    #[test]
    fn test_ol_error_implements_std_error() {
        // Test 5: OlError implements std::error::Error trait
        let err = OlError::new(ERR_EVENT_TOO_LARGE, "test");
        // Verify the trait bound by using it as &dyn std::error::Error
        let _boxed: Box<dyn std::error::Error> = Box::new(err);
    }

    #[test]
    fn test_bug_report_sets_code_ol_9999() {
        let err = OlError::bug_report("Unexpected panic in envelope module");
        assert_eq!(err.code, "OL-9999");
        assert!(err.suggestion.is_some());
        assert!(err.docs_url.is_some());
        let url = err.docs_url.unwrap();
        assert!(url.contains("github.com/OpenLatch/openlatch-client/issues/new"));
    }

    #[test]
    fn test_percent_encode_spaces_and_newlines() {
        assert_eq!(percent_encode("hello world"), "hello%20world");
        assert_eq!(percent_encode("line1\nline2"), "line1%0Aline2");
    }
}