collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
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
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
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
use super::paths::{ensure_config_dir, secrets_file_path};
use super::types::ConfigFile;
use crate::common::{AgentError, Result};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

// ── SecretsFile ──────────────────────────────────────────────────────────────

/// All sensitive credentials stored separately from `config.toml`.
/// Never commit this file — add `.collet/.secrets` to your `.gitignore`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsFile {
    #[serde(default)]
    pub api: SecretsApiSection,
    #[serde(default)]
    pub providers: Vec<SecretsProviderEntry>,
    #[serde(default)]
    pub web: SecretsWebSection,
    #[serde(default)]
    pub telegram: SecretsTelegramSection,
    #[serde(default)]
    pub slack: SecretsSlackSection,
    #[serde(default)]
    pub discord: SecretsDiscordSection,
    /// Base64-encoded random 32-byte salt for machine key derivation.
    /// Absent in secrets files created before this field was introduced;
    /// a new salt is generated on first access and the file is re-saved.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsApiSection {
    pub api_key_enc: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsProviderEntry {
    pub name: String,
    pub api_key_enc: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsWebSection {
    pub username: Option<String>,
    pub password_enc: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsTelegramSection {
    pub token_enc: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsSlackSection {
    pub bot_token_enc: Option<String>,
    pub app_token_enc: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsDiscordSection {
    pub token_enc: Option<String>,
}

/// Load secrets from `.secrets`. Returns empty default if file doesn't exist.
pub fn load_secrets() -> SecretsFile {
    let path = secrets_file_path();
    if !path.exists() {
        return SecretsFile::default();
    }
    let content = match std::fs::read_to_string(&path) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("warning: failed to read secrets file: {e}");
            return SecretsFile::default();
        }
    };
    match toml::from_str(&content) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("warning: secrets file is corrupted and will be ignored: {e}");
            SecretsFile::default()
        }
    }
}

/// Save a `SecretsFile` to `<collet_home>/.secrets` with mode 0600 on Unix.
pub fn save_secrets(secrets: &SecretsFile) -> Result<()> {
    let path = secrets_file_path();

    let toml_str = toml::to_string_pretty(secrets)
        .map_err(|e| AgentError::Config(format!("Failed to serialize secrets: {e}")))?;

    let header = "# collet secrets — DO NOT COMMIT\n\
                  # Generated by collet. Add to .gitignore: .collet/.secrets\n\n";
    let out = format!("{header}{toml_str}");

    // Atomic write: write to temp then rename to prevent partial reads.
    let tmp_path = path.with_extension("secrets.tmp");

    #[cfg(unix)]
    {
        use std::fs::OpenOptions;
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut f = OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(&tmp_path)
            .map_err(|e| AgentError::Config(format!("Failed to create temp secrets: {e}")))?;
        f.write_all(out.as_bytes())
            .map_err(|e| AgentError::Config(format!("Failed to write temp secrets: {e}")))?;
        f.sync_all()
            .map_err(|e| AgentError::Config(format!("Failed to sync temp secrets: {e}")))?;
    }

    #[cfg(not(unix))]
    {
        std::fs::write(&tmp_path, &out)
            .map_err(|e| AgentError::Config(format!("Failed to write temp secrets: {e}")))?;
    }

    std::fs::rename(&tmp_path, &path)
        .map_err(|e| AgentError::Config(format!("Failed to rename secrets file: {e}")))?;

    Ok(())
}

/// Extract all sensitive fields from a `ConfigFile` into a `SecretsFile`.
///
/// `existing_machine_id` should come from a prior `load_secrets()` call at the
/// call-site to avoid recursive I/O inside this function.
pub fn extract_secrets(cf: &ConfigFile, existing_machine_id: Option<String>) -> SecretsFile {
    SecretsFile {
        api: SecretsApiSection {
            api_key_enc: cf.api.api_key_enc.clone(),
        },
        providers: cf
            .providers
            .iter()
            .filter(|p| p.api_key_enc.is_some())
            .map(|p| SecretsProviderEntry {
                name: p.name.clone(),
                api_key_enc: p.api_key_enc.clone(),
            })
            .collect(),
        web: SecretsWebSection {
            username: cf.web.username.clone(),
            password_enc: cf.web.password_enc.clone(),
        },
        telegram: SecretsTelegramSection {
            token_enc: cf.telegram.token_enc.clone(),
        },
        slack: SecretsSlackSection {
            bot_token_enc: cf.slack.bot_token_enc.clone(),
            app_token_enc: cf.slack.app_token_enc.clone(),
        },
        discord: SecretsDiscordSection {
            token_enc: cf.discord.token_enc.clone(),
        },
        // Preserve the existing machine_id so that previously encrypted values
        // remain valid after extraction (e.g. during config migration).
        machine_id: existing_machine_id,
    }
}

/// Overlay a `SecretsFile` onto a `ConfigFile` (secrets win on conflict).
pub(super) fn merge_secrets(cf: &mut ConfigFile, secrets: &SecretsFile) {
    if secrets.api.api_key_enc.is_some() {
        cf.api.api_key_enc = secrets.api.api_key_enc.clone();
    }
    for sp in &secrets.providers {
        if let Some(pe) = cf.providers.iter_mut().find(|p| p.name == sp.name)
            && sp.api_key_enc.is_some()
        {
            pe.api_key_enc = sp.api_key_enc.clone();
        }
    }
    if secrets.web.username.is_some() {
        cf.web.username = secrets.web.username.clone();
    }
    if secrets.web.password_enc.is_some() {
        cf.web.password_enc = secrets.web.password_enc.clone();
    }
    if secrets.telegram.token_enc.is_some() {
        cf.telegram.token_enc = secrets.telegram.token_enc.clone();
    }
    if secrets.slack.bot_token_enc.is_some() {
        cf.slack.bot_token_enc = secrets.slack.bot_token_enc.clone();
    }
    if secrets.slack.app_token_enc.is_some() {
        cf.slack.app_token_enc = secrets.slack.app_token_enc.clone();
    }
    if secrets.discord.token_enc.is_some() {
        cf.discord.token_enc = secrets.discord.token_enc.clone();
    }
}

/// Scan well-known environment variables and return a `SecretsFile` with any
/// found values encrypted. The caller is responsible for merging and saving.
pub fn scan_env_to_secrets() -> SecretsFile {
    let mut s = SecretsFile::default();

    // Global API key — first match wins
    for var in &["COLLET_API_KEY"] {
        if let Ok(val) = std::env::var(var)
            && !val.is_empty()
        {
            if let Ok(enc) = encrypt_key(&val) {
                s.api.api_key_enc = Some(enc);
            }
            break;
        }
    }

    // Per-provider env vars
    const PROVIDER_VARS: &[(&str, &str)] = &[
        ("ANTHROPIC_API_KEY", "anthropic"),
        ("OPENAI_API_KEY", "openai"),
        ("GEMINI_API_KEY", "gemini"),
        ("GROQ_API_KEY", "groq"),
        ("MISTRAL_API_KEY", "mistral"),
    ];
    for &(var, provider) in PROVIDER_VARS {
        if let Ok(val) = std::env::var(var)
            && !val.is_empty()
            && let Ok(enc) = encrypt_key(&val)
        {
            // Also set as global key if not already set
            if s.api.api_key_enc.is_none() {
                s.api.api_key_enc = Some(enc.clone());
            }
            s.providers.push(SecretsProviderEntry {
                name: provider.to_string(),
                api_key_enc: Some(enc),
            });
        }
    }

    // Web password
    if let Ok(val) = std::env::var("COLLET_WEB_PASSWORD")
        && !val.is_empty()
        && let Ok(enc) = encrypt_key(&val)
    {
        s.web.password_enc = Some(enc);
    }

    // Telegram
    if let Ok(val) = std::env::var("TELEGRAM_BOT_TOKEN")
        && !val.is_empty()
        && let Ok(enc) = encrypt_key(&val)
    {
        s.telegram.token_enc = Some(enc);
    }

    // Slack
    if let Ok(val) = std::env::var("SLACK_BOT_TOKEN")
        && !val.is_empty()
        && let Ok(enc) = encrypt_key(&val)
    {
        s.slack.bot_token_enc = Some(enc);
    }
    if let Ok(val) = std::env::var("SLACK_APP_TOKEN")
        && !val.is_empty()
        && let Ok(enc) = encrypt_key(&val)
    {
        s.slack.app_token_enc = Some(enc);
    }

    // Discord
    if let Ok(val) = std::env::var("DISCORD_BOT_TOKEN")
        && !val.is_empty()
        && let Ok(enc) = encrypt_key(&val)
    {
        s.discord.token_enc = Some(enc);
    }

    s
}

/// Add `.collet/.secrets` (and other collet patterns) to `.gitignore` in
/// the git repository root. No-op if the entries already exist.
pub fn update_gitignore_for_secrets() {
    let start = match std::env::current_dir() {
        Ok(d) => d,
        Err(_) => return,
    };

    // Walk up directory tree to find git root.
    let git_root = {
        let mut dir = start.as_path();
        loop {
            if dir.join(".git").exists() {
                break Some(dir.to_path_buf());
            }
            match dir.parent() {
                Some(p) => dir = p,
                None => break None,
            }
        }
    };

    // Fall back to CWD if not in a git repo.
    let base = git_root.unwrap_or(start);
    let gitignore_path = base.join(".gitignore");
    let existing = std::fs::read_to_string(&gitignore_path).unwrap_or_default();

    const ENTRIES: &[&str] = &[".collet/.secrets", ".collet/*.secrets.tmp"];
    let mut additions = String::new();
    for entry in ENTRIES {
        if !existing.lines().any(|l| l.trim() == *entry) {
            additions.push_str(entry);
            additions.push('\n');
        }
    }
    if additions.is_empty() {
        return;
    }
    let new_content = if existing.is_empty() {
        format!("# collet\n{additions}")
    } else {
        format!("{}\n# collet\n{additions}", existing.trim_end())
    };
    let _ = std::fs::write(&gitignore_path, new_content);
}

// ---------------------------------------------------------------------------
// Key encryption (AES-256-GCM)
// ---------------------------------------------------------------------------

/// Resolve hostname with multiple fallbacks:
/// 1. `HOSTNAME` env var (set by most shells)
/// 2. `COMPUTERNAME` env var (Windows)
/// 3. `/proc/sys/kernel/hostname` direct read (Linux outside shell: systemd, cron)
/// 4. Static fallback `"collet-host"`
fn resolve_hostname() -> String {
    std::env::var("HOSTNAME")
        .or_else(|_| std::env::var("COMPUTERNAME"))
        .or_else(|_| {
            std::fs::read_to_string("/proc/sys/kernel/hostname").map(|s| s.trim().to_string())
        })
        .unwrap_or_else(|_| "collet-host".into())
}

/// Derive a 32-byte AES key from a random `salt` combined with the current
/// machine hostname and username. The per-installation salt ensures different
/// keys even for identical user@host pairs across machines.
fn derive_machine_key_with_salt(salt: &[u8]) -> [u8; 32] {
    let username = std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .unwrap_or_else(|_| "collet-user".into());

    let hostname = resolve_hostname();

    let mut hasher = blake3::Hasher::new();
    hasher.update(salt);
    hasher.update(b"collet:");
    hasher.update(hostname.as_bytes());
    hasher.update(b":");
    hasher.update(username.as_bytes());
    *hasher.finalize().as_bytes()
}

/// Return the 32-byte salt stored in the secrets file as `machine_id`.
/// If absent or invalid, generate a fresh random salt, persist it, and
/// return it.
///
/// # Behaviour on legacy secrets files
/// Files without `machine_id` receive a freshly generated salt. This
/// intentionally invalidates any previously encrypted values — `decrypt_key`
/// returns a descriptive error instructing the user to re-enter credentials.
fn get_or_create_machine_id() -> [u8; 32] {
    static MACHINE_ID: OnceLock<[u8; 32]> = OnceLock::new();
    *MACHINE_ID.get_or_init(|| {
        use base64::Engine;

        let mut secrets = load_secrets();

        if let Some(ref encoded) = secrets.machine_id {
            if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded)
                && bytes.len() == 32
            {
                let mut salt = [0u8; 32];
                salt.copy_from_slice(&bytes);
                return salt;
            }
            eprintln!("warning: machine_id in secrets file is invalid; regenerating");
        }

        // Generate a fresh random salt and persist it.
        let salt: [u8; 32] = rand::random();
        secrets.machine_id = Some(base64::engine::general_purpose::STANDARD.encode(salt));

        // Best-effort persist — if it fails the in-memory salt is still used for
        // this process but will be regenerated on next start (user re-enters creds once).
        if let Err(e) = save_secrets(&secrets) {
            eprintln!("warning: could not persist machine_id to secrets file: {e}");
        }

        salt
    })
}

/// Encrypt an API key and return base64-encoded ciphertext.
pub fn encrypt_key(plaintext: &str) -> Result<String> {
    use aes_gcm::aead::Aead;
    use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
    use base64::Engine;

    let salt = get_or_create_machine_id();
    let key_bytes = derive_machine_key_with_salt(&salt);
    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
        .map_err(|e| AgentError::Config(format!("cipher init failed: {e}")))?;

    let nonce_bytes: [u8; 12] = rand::random();
    let nonce = Nonce::from_slice(&nonce_bytes);

    let ciphertext = cipher
        .encrypt(nonce, plaintext.as_bytes())
        .map_err(|e| AgentError::Config(format!("encryption failed: {e}")))?;

    let mut combined = Vec::with_capacity(12 + ciphertext.len());
    combined.extend_from_slice(&nonce_bytes);
    combined.extend_from_slice(&ciphertext);

    Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
}

/// Decrypt a base64-encoded encrypted API key.
pub fn decrypt_key(encoded: &str) -> Result<String> {
    use aes_gcm::aead::Aead;
    use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
    use base64::Engine;

    let combined = base64::engine::general_purpose::STANDARD
        .decode(encoded)
        .map_err(|e| AgentError::Config(format!("Failed to decode base64 encrypted key: {}", e)))?;

    if combined.len() < 13 {
        return Err(AgentError::Config("Encrypted key too short".to_string()));
    }

    let (nonce_bytes, ciphertext) = combined.split_at(12);

    let salt = get_or_create_machine_id();
    let key_bytes = derive_machine_key_with_salt(&salt);

    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
        .map_err(|e| AgentError::Config(format!("cipher init failed: {e}")))?;
    let nonce = Nonce::from_slice(nonce_bytes);

    let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| {
        AgentError::Config(
            "Decryption failed — the stored credentials were encrypted with a different \
             machine_id (legacy file or different machine). \
             Please re-enter your credentials (`collet config --set-key`)."
                .to_string(),
        )
    })?;

    String::from_utf8(plaintext)
        .map_err(|e| AgentError::Config(format!("Decrypted key is not valid UTF-8: {e}")))
}

/// Encrypt `key`, update config file: set api_key_enc, clear api_key.
/// Used by wizard_key_only() and cmd_secure().
pub fn save_encrypted_key(key: &str) -> Result<()> {
    let encrypted = encrypt_key(key)?;

    // Verify round-trip before writing
    let decrypted = decrypt_key(&encrypted)?;
    if decrypted != key {
        return Err(AgentError::Config(
            "Encryption verification failed: decrypted value does not match original.".to_string(),
        ));
    }

    let dir = ensure_config_dir()?;
    let path = dir.join("config.toml");

    let mut cf = if path.exists() {
        let content = std::fs::read_to_string(&path)?;
        toml::from_str::<ConfigFile>(&content).unwrap_or_default()
    } else {
        ConfigFile::default()
    };

    cf.api.api_key_enc = Some(encrypted);
    cf.api.api_key = None;

    let toml_str = toml::to_string_pretty(&cf)
        .map_err(|e| AgentError::Config(format!("Failed to serialize config: {e}")))?;

    // Atomic write via temp file + rename to prevent partial reads by concurrent processes.
    // Create with 0o600 permissions from the start (owner-read/write only) to avoid any
    // window where the file containing an encrypted API key is world-readable.
    let tmp_path = path.with_extension("toml.tmp");
    #[cfg(unix)]
    {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut f = std::fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(&tmp_path)
            .map_err(|e| {
                AgentError::Config(format!(
                    "Failed to create temp config {}: {e}",
                    tmp_path.display()
                ))
            })?;
        f.write_all(toml_str.as_bytes()).map_err(|e| {
            AgentError::Config(format!(
                "Failed to write temp config {}: {e}",
                tmp_path.display()
            ))
        })?;
        f.sync_all().map_err(|e| {
            AgentError::Config(format!(
                "Failed to sync temp config {}: {e}",
                tmp_path.display()
            ))
        })?;
    }
    #[cfg(not(unix))]
    std::fs::write(&tmp_path, &toml_str).map_err(|e| {
        AgentError::Config(format!(
            "Failed to write temp config {}: {e}",
            tmp_path.display()
        ))
    })?;
    std::fs::rename(&tmp_path, &path).map_err(|e| {
        AgentError::Config(format!("Failed to replace config {}: {e}", path.display()))
    })?;

    eprintln!("✅ API key encrypted and saved.");
    Ok(())
}

/// Encrypt web password (and optionally username), update config file.
///
/// Delegates to [`crate::config::wizard::ui::save_web_credentials`].
pub fn save_web_credentials(username: Option<String>, password: &str) -> Result<()> {
    crate::config::wizard::ui::save_web_credentials(username, password)
}