Skip to main content

kcode_kennedy_app/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::Arc,
7};
8
9use anyhow::Context;
10use clap::{Parser, Subcommand};
11use kcode_credential_vault::{CredentialVault, ExposeSecret, SecretString};
12use kcode_kweb_db::{Config as KwebConfig, NoopGossip, WriterId};
13use kcode_speaker_system::SpeechClassifier;
14use zeroize::{Zeroize, Zeroizing};
15
16const OPENAI_API_KEY_SECRET: &str = "openai-api-key";
17const GEMINI_API_KEY_SECRET: &str = "gemini-api-key";
18const TELEGRAM_BOT_TOKEN_SECRET: &str = "telegram-bot-token";
19const CRATES_IO_KEY_SECRET: &str = "cratesio-key";
20const KWEB_WRITER_SIGNING_KEY_SECRET: &str = "kweb-writer-signing-key";
21const KWEB_WRITERS_SECRET: &str = "kweb-writers-by-priority";
22const SPEECH_CLASSIFICATION_DATABASE_PATH: &str = "./data/kennedy-speech-classification.sqlite3";
23
24#[derive(Parser, Debug)]
25struct Args {
26    #[arg(long, global = true, default_value = "./data/kennedy-secrets.age")]
27    vault_path: PathBuf,
28    #[command(subcommand)]
29    command: Option<Command>,
30    #[arg(long, global = true, default_value = "127.0.0.1:4321")]
31    kweb_bind: String,
32    #[arg(long, global = true, default_value = "./data/kweb")]
33    kweb_root: PathBuf,
34    #[arg(
35        long,
36        global = true,
37        default_value = "./data/kennedy-conversations.sqlite3"
38    )]
39    conversation_history_database: PathBuf,
40    #[arg(long, global = true, default_value = "./data/sessions/in-progress")]
41    session_directory: PathBuf,
42    #[arg(long, global = true, default_value = "./data/session-history.txt")]
43    session_history_file: PathBuf,
44    #[arg(long, global = true, default_value = "./data/kennedy-telegram.sqlite3")]
45    telegram_database: PathBuf,
46    #[arg(long, global = true, default_value = "./data/kennedy-users.sqlite3")]
47    user_database: PathBuf,
48    #[arg(
49        long,
50        global = true,
51        default_value = "./data/kennedy-task-board.sqlite3"
52    )]
53    task_board_database: PathBuf,
54    #[arg(long, global = true, default_value = "./data/kennedy-credits.sqlite3")]
55    credits_database: PathBuf,
56    #[arg(
57        long,
58        alias = "audio-ingress-database",
59        global = true,
60        default_value = "./data/kennedy-audio.sqlite3",
61        help = "Optional pre-library AudioIngress database used only for one-time migration"
62    )]
63    legacy_audio_ingress_database: PathBuf,
64    #[arg(
65        long,
66        alias = "audio-ingress-media",
67        global = true,
68        default_value = "./data/audio-ingress-media",
69        help = "AudioIngress-owned persistence root (database and original audio)"
70    )]
71    audio_ingress_directory: PathBuf,
72    #[arg(
73        long,
74        global = true,
75        default_value = "./data/intelligence-usage",
76        help = "One-file-per-call intelligence usage receipt directory"
77    )]
78    intelligence_usage_directory: PathBuf,
79    #[arg(long, default_value = "./data/kcode/kcode-rust-libs")]
80    rust_libs_root: PathBuf,
81    #[arg(long, default_value = "./data/kcode/kcode-web-libs")]
82    web_libs_root: PathBuf,
83    #[arg(long, default_value = "./data/kcode/kcode-web-libs-published")]
84    web_libs_published_root: PathBuf,
85    #[arg(long, default_value = "./data/kcode/kcode-rust-bins")]
86    rust_bins_root: PathBuf,
87    #[arg(long, default_value = "./data/kcode/kcode-rust-bin-artifacts")]
88    rust_bin_artifacts_root: PathBuf,
89    #[arg(long, default_value = "@taek42")]
90    telegram_bootstrap_username: String,
91    #[arg(long, default_value_t = 20 * 1024 * 1024)]
92    telegram_max_voice_bytes: usize,
93    #[arg(long, default_value_t = 8 * 1024 * 1024 * 1024)]
94    audio_ingress_max_upload_bytes: usize,
95}
96
97#[derive(Subcommand, Debug)]
98enum Command {
99    /// Create and manage generic named secrets in Kennedy's encrypted vault.
100    Secrets {
101        #[command(subcommand)]
102        command: SecretsCommand,
103    },
104    /// Estimate the token footprint of all current Kmap node text.
105    KmapSize,
106}
107
108#[derive(Subcommand, Debug)]
109enum SecretsCommand {
110    /// Prompt for and store a named secret, replacing any previous value.
111    Set { name: String },
112    /// Remove a named secret without displaying its value.
113    Remove { name: String },
114    /// List configured secret names without displaying their values.
115    List,
116    /// Re-encrypt the vault with a new passphrase.
117    ChangePassphrase,
118}
119
120#[tokio::main]
121pub async fn main() -> anyhow::Result<()> {
122    tracing_subscriber::fmt()
123        .with_env_filter(
124            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
125                "kennedy_server=info,kcode_kennedy_app=info,kcode_kennedy_orchestration=info,kcode_kennedy_telegram_runtime=info,kcode_kennedy_roots=info,kcode_kweb_db=info,kcode_codex_runtime=info,kcode_session_history=info,kcode_tg_kennedy_bot=info,tower_http=info".into()
126            }),
127        )
128        .init();
129    rustls::crypto::ring::default_provider()
130        .install_default()
131        .map_err(|_| anyhow::anyhow!("installing TLS crypto provider"))?;
132    let mut args = Args::parse();
133    let vault_path = args.vault_path.clone();
134    match args.command.take() {
135        Some(Command::Secrets { command }) => {
136            let _maintenance_guard = tokio::net::TcpListener::bind(&args.kweb_bind)
137                .await
138                .with_context(|| {
139                    format!(
140                        "binding maintenance lock {}; stop the running Kennedy server before changing its credential vault",
141                        args.kweb_bind
142                    )
143                })?;
144            manage_secrets(command, &vault_path)
145        }
146        Some(Command::KmapSize) => {
147            let _maintenance_guard =
148                maintenance_guard(&args.kweb_bind, "measuring the Kweb").await?;
149            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
150            let vault = CredentialVault::unlock(&vault_path, passphrase)?;
151            let size = kcode_kmap_size::measure(&args.kweb_root, kweb_config(&vault)?)?;
152            println!("{}", kcode_kmap_size::render(&size));
153            Ok(())
154        }
155        None => run_server(args, vault_path).await,
156    }
157}
158
159async fn run_server(args: Args, vault_path: PathBuf) -> anyhow::Result<()> {
160    // Bind the public Kennedy address before opening any persistent state.
161    // Offline maintenance checks this address before copying the data tree.
162    let kweb_listener = tokio::net::TcpListener::bind(&args.kweb_bind)
163        .await
164        .with_context(|| format!("binding Kweb listener {}", args.kweb_bind))?;
165    ensure_runtime_parent_directories(&args, &vault_path)?;
166    let vault = if vault_path.exists() {
167        let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
168        CredentialVault::unlock(&vault_path, passphrase)?
169    } else {
170        tracing::warn!(path=%vault_path.display(), "Kennedy credential vault does not exist; secret-backed features are unavailable");
171        CredentialVault::empty()
172    };
173    let openai_api_key = resolve_optional_secret(
174        &vault,
175        OPENAI_API_KEY_SECRET,
176        "OpenAI transcription, media annotation, agents, and image generation/editing",
177    )?;
178    let gemini_api_key = resolve_optional_secret(
179        &vault,
180        GEMINI_API_KEY_SECRET,
181        "Gemini search, media annotation, agents, audio transcription, and image generation/editing",
182    )?;
183    let telegram_bot_token =
184        resolve_optional_secret(&vault, TELEGRAM_BOT_TOKEN_SECRET, "Telegram relay")?
185            .map(kcode_tg_kennedy_bot::BotToken::new)
186            .transpose()?;
187    let crates_io_key =
188        resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "Rust library publication")?;
189    let kweb_config = kweb_config(&vault)?;
190    let codex_catalog_cache =
191        kcode_codex_runtime::CatalogCache::new(kcode_codex_runtime::DEFAULT_CODEX_EXECUTABLE);
192    let (kmap, system_roots) =
193        kcode_kennedy_roots::open(&args.kweb_root, kweb_config, &args.user_database)?;
194    let (kmap_commands, kmap_command_runtime) =
195        kcode_kmap_command_lane::open(&args.user_database, kmap.clone())?;
196    let credits = kcode_credits::Credits::open(&args.credits_database)?;
197    let task_board = kcode_task_board::TaskBoard::open(&args.task_board_database, credits.clone())?;
198    let speech_classifier = SpeechClassifier::open(SPEECH_CLASSIFICATION_DATABASE_PATH)
199        .with_context(|| {
200            format!("opening speaker-classification database {SPEECH_CLASSIFICATION_DATABASE_PATH}")
201        })?;
202    let speech_classifier = Arc::new(speech_classifier);
203    let dev_tools = kcode_dev_tools::Service::open(kcode_dev_tools::Config {
204        rust_libraries_root: args.rust_libs_root.clone(),
205        web_libraries_root: args.web_libs_root.clone(),
206        web_publications_root: args.web_libs_published_root.clone(),
207        rust_binaries_root: args.rust_bins_root.clone(),
208        rust_binary_publications_root: args.rust_bin_artifacts_root.clone(),
209        crates_io_registry_token: crates_io_key,
210    })
211    .map_err(anyhow::Error::new)
212    .with_context(|| {
213        format!(
214            "opening managed Kcode development roots under {}",
215            args.rust_libs_root
216                .parent()
217                .unwrap_or(Path::new("."))
218                .display()
219        )
220    })?;
221    let web_publications_root = dev_tools.web_publications_root().to_path_buf();
222    let telegram_identity = std::sync::Arc::new(kcode_telegram_identity::Directory::open(
223        &args.user_database,
224        &args.telegram_bootstrap_username,
225    )?);
226    let history_service =
227        kcode_session_history::SessionHistory::open(kcode_session_history::Config {
228            directory: args.session_directory,
229            completed_list: args.session_history_file,
230            provider_cost_compatibility: Some(
231                kcode_intelligence_chatend::provider_cost_compatibility(),
232            ),
233        })?;
234    let (intelligence_service, intelligence_runtime) =
235        kcode_intelligence_router::open(kcode_intelligence_router::Config {
236            openai_api_key,
237            gemini_api_key,
238            codex_catalog_cache,
239            receipt_directory: args.intelligence_usage_directory,
240        })
241        .await?;
242    let agent_runtime = kcode_agent_runtime::AgentRuntime::new(intelligence_service.clone());
243    let telegram_runtime = kcode_tg_kennedy_bot::open(kcode_tg_kennedy_bot::Config {
244        database: args.telegram_database,
245        bot_token: telegram_bot_token,
246        identity_sink: telegram_identity.clone(),
247        max_voice_bytes: args.telegram_max_voice_bytes,
248    })
249    .await?;
250    let telegram_service = telegram_runtime.service();
251    let chunk_intelligence = intelligence_service.clone();
252    let transcribe_chunk: kcode_audio_ingress::AudioChunkCall = Arc::new(move |request| {
253        let intelligence = chunk_intelligence.clone();
254        Box::pin(async move {
255            let user = intelligence
256                .for_user(request.user_id)
257                .map_err(audio_intelligence_error)?;
258            let media = kcode_intelligence_router::Media::audio(
259                request.audio_ogg,
260                "audio-chunk.ogg",
261                "audio/ogg",
262            )
263            .map_err(audio_intelligence_error)?;
264            user.analyze_audio(kcode_intelligence_router::AudioAnalysisRequest {
265                operation: "transcribe_chunk".into(),
266                prompt: request.prompt,
267                model: request.model,
268                media,
269                schema: request.schema,
270                max_output_tokens: request.max_output_tokens,
271                temperature: None,
272                operation_id: uuid::Uuid::new_v4(),
273                parent_operation_id: None,
274            })
275            .await
276            .map(|response| response.value.text)
277            .map_err(audio_intelligence_error)
278        })
279    });
280    let text_intelligence = intelligence_service.clone();
281    let generate_text: kcode_audio_ingress::TextGenerationCall = Arc::new(move |request| {
282        let intelligence = text_intelligence.clone();
283        Box::pin(async move {
284            let reasoning_effort = match request.reasoning_effort.as_str() {
285                "xhigh" => kcode_intelligence_router::ReasoningEffort::XHigh,
286                _ => {
287                    return Err(kcode_audio_ingress::IntelligenceError::new(
288                        "AudioIngress requested an unsupported reasoning effort.",
289                        false,
290                    ));
291                }
292            };
293            let user = intelligence
294                .for_user(request.user_id)
295                .map_err(audio_intelligence_error)?;
296            user.generate_text(kcode_intelligence_router::TextGenerationRequest {
297                operation: request.operation,
298                prompt: request.prompt,
299                model: request.model,
300                reasoning_effort,
301                timeout: request.timeout,
302                operation_id: uuid::Uuid::new_v4(),
303                parent_operation_id: None,
304            })
305            .await
306            .map(|response| response.value.text)
307            .map_err(audio_intelligence_error)
308        })
309    });
310    let audio_transcriber =
311        kcode_audio_ingress::AudioTranscriber::new(transcribe_chunk, generate_text);
312    let audio_state_database = args.audio_ingress_directory.join("state.sqlite3");
313    migrate_audio_ingress_database(&args.legacy_audio_ingress_database, &audio_state_database)?;
314    let audio = kcode_audio_ingress::AudioIngress::open(
315        &args.audio_ingress_directory,
316        audio_transcriber,
317        Arc::clone(&speech_classifier),
318    )
319    .await?;
320    let audio_coordinator = kcode_audio_session_ingress::Coordinator::new(
321        audio,
322        history_service.clone(),
323        kcode_audio_session_ingress::Config {
324            user_id: system_roots.user.to_string(),
325            effective_context_tokens: intelligence_runtime.context_window_tokens,
326        },
327    )?;
328    let http_router = kcode_http_api::router(kcode_http_api::Config {
329        kmap: kmap.clone(),
330        kmap_commands,
331        user_root_node_id: system_roots.user,
332        kennedy_root_node_id: system_roots.kennedy,
333        telegram: telegram_service.clone(),
334        session_history: history_service.clone(),
335        audio_ingress: audio_coordinator.clone(),
336        audio_max_upload_bytes: args.audio_ingress_max_upload_bytes,
337        task_board: task_board.clone(),
338        credits,
339        web_publications_root,
340    })?;
341    let orchestration_config = kcode_kennedy_orchestration::Config {
342        user_root_node_id: system_roots.user.to_string(),
343        kennedy_root_node_id: system_roots.kennedy.to_string(),
344        telegram_max_media_bytes: args.telegram_max_voice_bytes,
345        runtime_model: kcode_kennedy_orchestration::RuntimeModel::from_intelligence(
346            intelligence_runtime,
347        ),
348    };
349    let telegram_sessions = kcode_telegram_session_coordinator::Service::new(
350        telegram_service.clone(),
351        telegram_identity.clone(),
352    );
353    let session_service =
354        kcode_kennedy_sessions::Service::new(kcode_kennedy_sessions::Capabilities {
355            kmap: kmap.clone(),
356            intelligence: intelligence_service.clone(),
357            agents: agent_runtime,
358            history: history_service.clone(),
359            speech_classifier,
360            dev_tools: dev_tools.clone(),
361            telegram: telegram_sessions,
362        })
363        .with_task_board(task_board);
364    let orchestration_api = kcode_kennedy_orchestration::Api::new(
365        &orchestration_config,
366        kcode_kennedy_orchestration::LocalServices {
367            kmap: kmap.clone(),
368            intelligence: intelligence_service,
369            history: history_service.clone(),
370            audio: audio_coordinator,
371            directory: telegram_identity.clone(),
372            dev_tools,
373            telegram: telegram_service,
374        },
375    );
376    let orchestration_worker = kcode_kennedy_orchestration::build(
377        orchestration_config,
378        orchestration_api,
379        session_service,
380    );
381    let directory_roots = kcode_kennedy_roots::DirectoryRoots::new(
382        kmap,
383        telegram_identity,
384        args.telegram_bootstrap_username.clone(),
385        system_roots.user,
386        orchestration_worker.writer().clone(),
387    );
388    let telegram_session_runtime = Arc::new(kcode_kennedy_telegram_runtime::Runtime::new(
389        kcode_kennedy_telegram_runtime::Config {
390            telegram_max_media_bytes: args.telegram_max_voice_bytes,
391            telegram_web_user_handle: args.telegram_bootstrap_username,
392        },
393        orchestration_worker.clone(),
394        directory_roots,
395    ));
396    tokio::try_join!(
397        async {
398            kcode_http_api::serve(kweb_listener, http_router)
399                .await
400                .map_err(anyhow::Error::new)
401        },
402        telegram_runtime.run(),
403        kcode_kennedy_orchestration::run(orchestration_worker),
404        telegram_session_runtime.run(),
405        async { kmap_command_runtime.await.map_err(anyhow::Error::new) },
406    )?;
407    Ok(())
408}
409
410fn audio_intelligence_error(
411    error: kcode_intelligence_router::Error,
412) -> kcode_audio_ingress::IntelligenceError {
413    let retryable = error.retryable();
414    kcode_audio_ingress::IntelligenceError::new(error.message(), retryable)
415}
416
417fn ensure_runtime_parent_directories(args: &Args, vault_path: &Path) -> anyhow::Result<()> {
418    for path in [
419        vault_path,
420        &args.kweb_root,
421        &args.conversation_history_database,
422        &args.session_directory,
423        &args.session_history_file,
424        &args.telegram_database,
425        &args.user_database,
426        &args.task_board_database,
427        &args.credits_database,
428        Path::new(SPEECH_CLASSIFICATION_DATABASE_PATH),
429        &args.legacy_audio_ingress_database,
430        &args.audio_ingress_directory,
431        &args.intelligence_usage_directory,
432        &args.rust_libs_root,
433        &args.web_libs_root,
434        &args.web_libs_published_root,
435        &args.rust_bins_root,
436        &args.rust_bin_artifacts_root,
437    ] {
438        let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) else {
439            continue;
440        };
441        if parent.exists() {
442            continue;
443        }
444        let mut builder = std::fs::DirBuilder::new();
445        builder.recursive(true);
446        #[cfg(unix)]
447        {
448            use std::os::unix::fs::DirBuilderExt;
449            builder.mode(0o700);
450        }
451        builder
452            .create(parent)
453            .with_context(|| format!("creating runtime data directory {}", parent.display()))?;
454    }
455    Ok(())
456}
457
458fn migrate_audio_ingress_database(legacy: &Path, current: &Path) -> anyhow::Result<()> {
459    if current.exists() || !legacy.exists() {
460        return Ok(());
461    }
462    if let Some(parent) = current.parent() {
463        std::fs::create_dir_all(parent)
464            .with_context(|| format!("creating AudioIngress root {}", parent.display()))?;
465    }
466    let source = rusqlite::Connection::open(legacy)
467        .with_context(|| format!("opening legacy AudioIngress database {}", legacy.display()))?;
468    source
469        .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
470        .context("checkpointing legacy AudioIngress database")?;
471    source
472        .backup(rusqlite::MAIN_DB, current, None)
473        .context("copying legacy AudioIngress database into its persistence root")?;
474    let destination = rusqlite::Connection::open(current)
475        .with_context(|| format!("opening AudioIngress database {}", current.display()))?;
476    destination
477        .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
478        .context("syncing migrated AudioIngress database")?;
479    tracing::info!(
480        source = %legacy.display(),
481        destination = %current.display(),
482        "Migrated AudioIngress database into its owned persistence root"
483    );
484    Ok(())
485}
486
487pub async fn maintenance_guard(
488    bind: &str,
489    purpose: &str,
490) -> anyhow::Result<tokio::net::TcpListener> {
491    tokio::net::TcpListener::bind(bind).await.with_context(|| {
492        format!("binding maintenance lock {bind}; stop the running Kennedy server before {purpose}")
493    })
494}
495
496fn kweb_config(vault: &CredentialVault) -> anyhow::Result<KwebConfig> {
497    let encoded_key = resolve_required_secret(
498        vault,
499        KWEB_WRITER_SIGNING_KEY_SECRET,
500        "Kweb mutation signing",
501    )?;
502    let mut signing_key = Zeroizing::new([0_u8; 32]);
503    let decoded = hex::decode(encoded_key.trim())
504        .context("Kweb writer signing key must be 64 lowercase hexadecimal characters")?;
505    *signing_key = decoded
506        .try_into()
507        .map_err(|_| anyhow::anyhow!("Kweb writer signing key must decode to exactly 32 bytes"))?;
508    let encoded_writers =
509        resolve_required_secret(vault, KWEB_WRITERS_SECRET, "Kweb writer authorization")?;
510    let writers_by_priority = encoded_writers
511        .split(',')
512        .map(str::trim)
513        .filter(|value| !value.is_empty())
514        .map(WriterId::from_str)
515        .collect::<Result<Vec<_>, _>>()
516        .map_err(anyhow::Error::new)
517        .context("decoding the ordered Kweb writer whitelist")?;
518    anyhow::ensure!(
519        !writers_by_priority.is_empty(),
520        "the Kweb writer whitelist is empty"
521    );
522    Ok(KwebConfig {
523        signing_key: *signing_key,
524        writers_by_priority,
525        gossip: Arc::new(NoopGossip),
526    })
527}
528
529fn resolve_optional_secret(
530    vault: &CredentialVault,
531    configured_name: &str,
532    purpose: &str,
533) -> anyhow::Result<Option<String>> {
534    let name = configured_name.trim();
535    if name.is_empty() {
536        return Ok(None);
537    }
538    let secret = vault.secret(name)?;
539    if secret.is_none() {
540        tracing::warn!(secret_name=name, %purpose, "configured Kennedy secret is not present in the vault");
541    }
542    Ok(secret.map(|value| value.expose_secret().to_owned()))
543}
544
545fn resolve_required_secret(
546    vault: &CredentialVault,
547    configured_name: &str,
548    purpose: &str,
549) -> anyhow::Result<String> {
550    let name = configured_name.trim();
551    if name.is_empty() {
552        anyhow::bail!("{purpose} requires a configured Kennedy secret name");
553    }
554    vault
555        .secret(name)?
556        .map(|value| value.expose_secret().to_owned())
557        .with_context(|| {
558            format!(
559                "{purpose} requires Kennedy secret '{name}'; store it with `kennedy-server secrets set {name}`"
560            )
561        })
562}
563
564fn manage_secrets(command: SecretsCommand, vault_path: &Path) -> anyhow::Result<()> {
565    match command {
566        SecretsCommand::Set { name } => {
567            let (mut vault, passphrase) = unlock_for_edit(vault_path)?;
568            let value = prompt_confirmed_value(&format!("Value for {name}: "))?;
569            vault.set(&name, value)?;
570            vault.save(vault_path, &passphrase)?;
571            println!("Stored Kennedy secret '{name}'.");
572        }
573        SecretsCommand::Remove { name } => {
574            if !vault_path.exists() {
575                println!("No Kennedy credential vault exists yet.");
576                return Ok(());
577            }
578            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
579            let mut vault = CredentialVault::unlock(vault_path, passphrase.clone())?;
580            if vault.remove(&name)? {
581                vault.save(vault_path, &passphrase)?;
582                println!("Removed Kennedy secret '{name}'.");
583            } else {
584                println!("Kennedy secret '{name}' was not configured.");
585            }
586        }
587        SecretsCommand::List => {
588            if !vault_path.exists() {
589                println!("No Kennedy credential vault exists yet.");
590                return Ok(());
591            }
592            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
593            let vault = CredentialVault::unlock(vault_path, passphrase)?;
594            let names = vault.names().collect::<Vec<_>>();
595            if names.is_empty() {
596                println!("The Kennedy credential vault contains no secrets.");
597            } else {
598                println!("Configured Kennedy secrets:");
599                for name in names {
600                    println!("- {name}");
601                }
602            }
603        }
604        SecretsCommand::ChangePassphrase => {
605            if !vault_path.exists() {
606                println!("No Kennedy credential vault exists yet.");
607                return Ok(());
608            }
609            let old = prompt_passphrase("Unlock Kennedy credential vault: ")?;
610            let vault = CredentialVault::unlock(vault_path, old)?;
611            let new = prompt_new_vault_passphrase()?;
612            vault.save(vault_path, &new)?;
613            println!("Changed the Kennedy credential vault passphrase.");
614        }
615    }
616    Ok(())
617}
618
619fn unlock_for_edit(path: &Path) -> anyhow::Result<(CredentialVault, SecretString)> {
620    if path.exists() {
621        let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
622        let vault = CredentialVault::unlock(path, passphrase.clone())?;
623        Ok((vault, passphrase))
624    } else {
625        let passphrase = prompt_new_vault_passphrase()?;
626        Ok((CredentialVault::empty(), passphrase))
627    }
628}
629
630fn prompt_passphrase(prompt: &str) -> anyhow::Result<SecretString> {
631    let mut value = rpassword::prompt_password(prompt)?;
632    if value.is_empty() {
633        value.zeroize();
634        anyhow::bail!("the credential vault passphrase cannot be empty");
635    }
636    Ok(SecretString::from(value))
637}
638
639fn prompt_new_vault_passphrase() -> anyhow::Result<SecretString> {
640    let mut first = rpassword::prompt_password("Create Kennedy credential vault passphrase: ")?;
641    let mut second = rpassword::prompt_password("Confirm credential vault passphrase: ")?;
642    if first.is_empty() || first != second {
643        first.zeroize();
644        second.zeroize();
645        anyhow::bail!("credential vault passphrases were empty or did not match");
646    }
647    second.zeroize();
648    Ok(SecretString::from(first))
649}
650
651fn prompt_confirmed_value(prompt: &str) -> anyhow::Result<String> {
652    let mut first = rpassword::prompt_password(prompt)?;
653    let mut second = rpassword::prompt_password("Confirm secret value: ")?;
654    if first.is_empty() || first != second {
655        first.zeroize();
656        second.zeroize();
657        anyhow::bail!("secret values were empty or did not match");
658    }
659    second.zeroize();
660    Ok(first)
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn secret_names_are_stable_code_defaults() {
669        assert_eq!(OPENAI_API_KEY_SECRET, "openai-api-key");
670        assert_eq!(GEMINI_API_KEY_SECRET, "gemini-api-key");
671        assert_eq!(TELEGRAM_BOT_TOKEN_SECRET, "telegram-bot-token");
672        assert_eq!(CRATES_IO_KEY_SECRET, "cratesio-key");
673        assert_eq!(KWEB_WRITER_SIGNING_KEY_SECRET, "kweb-writer-signing-key");
674        assert_eq!(KWEB_WRITERS_SECRET, "kweb-writers-by-priority");
675    }
676
677    #[test]
678    fn persistent_path_defaults_are_under_data() {
679        let args = Args::try_parse_from(["kennedy-server"]).unwrap();
680        for path in [
681            &args.vault_path,
682            &args.kweb_root,
683            &args.conversation_history_database,
684            &args.session_directory,
685            &args.session_history_file,
686            &args.telegram_database,
687            &args.user_database,
688            &args.task_board_database,
689            &args.credits_database,
690            &args.legacy_audio_ingress_database,
691            &args.audio_ingress_directory,
692            &args.intelligence_usage_directory,
693            &args.rust_libs_root,
694            &args.web_libs_root,
695            &args.web_libs_published_root,
696            &args.rust_bins_root,
697            &args.rust_bin_artifacts_root,
698        ] {
699            assert!(
700                path.starts_with("./data"),
701                "persistent default is outside data/: {}",
702                path.display()
703            );
704        }
705        for path in [
706            &args.rust_libs_root,
707            &args.web_libs_root,
708            &args.web_libs_published_root,
709            &args.rust_bins_root,
710            &args.rust_bin_artifacts_root,
711        ] {
712            assert!(
713                path.starts_with("./data/kcode"),
714                "managed Kcode default is outside data/kcode/: {}",
715                path.display()
716            );
717        }
718    }
719
720    #[test]
721    fn native_orchestration_remains_a_rust_backend_concern() {
722        assert_eq!(
723            std::any::type_name::<kcode_kennedy_orchestration::Session>(),
724            "kcode_kennedy_sessions::Session"
725        );
726    }
727
728    #[tokio::test]
729    async fn unified_dev_tools_service_opens_all_roots_and_routes_three_source_kinds() {
730        let directory = std::env::temp_dir().join(format!(
731            "kennedy-dev-tools-open-test-{}",
732            uuid::Uuid::new_v4()
733        ));
734        let rust_libraries = directory.join("kcode-rust-libs");
735        let web_libraries = directory.join("kcode-web-libs");
736        let web_publications = directory.join("kcode-web-libs-published");
737        let rust_binaries = directory.join("kcode-rust-bins");
738        let rust_binary_artifacts = directory.join("kcode-rust-bin-artifacts");
739        let service = kcode_dev_tools::Service::open(kcode_dev_tools::Config {
740            rust_libraries_root: rust_libraries.clone(),
741            web_libraries_root: web_libraries.clone(),
742            web_publications_root: web_publications.clone(),
743            rust_binaries_root: rust_binaries.clone(),
744            rust_binary_publications_root: rust_binary_artifacts.clone(),
745            crates_io_registry_token: "test-token".into(),
746        })
747        .unwrap();
748
749        assert_eq!(
750            service.web_libraries_root(),
751            std::fs::canonicalize(&web_libraries).unwrap()
752        );
753        assert_eq!(
754            service.web_publications_root(),
755            std::fs::canonicalize(&web_publications).unwrap()
756        );
757        for path in [
758            rust_libraries,
759            web_libraries,
760            web_publications,
761            rust_binaries,
762            rust_binary_artifacts,
763        ] {
764            assert!(
765                path.is_dir(),
766                "managed root was not created: {}",
767                path.display()
768            );
769        }
770        for (create, open, write, name, path, kind) in [
771            (
772                kcode_dev_tools::CREATE_RUST_LIB_TOOL,
773                kcode_dev_tools::OPEN_RUST_LIB_TOOL,
774                kcode_dev_tools::WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
775                "kennedy-test-lib",
776                "src/extra.rs",
777                kcode_dev_tools::ManagedSourceKind::RustLibrary,
778            ),
779            (
780                kcode_dev_tools::CREATE_WEB_LIB_TOOL,
781                kcode_dev_tools::OPEN_WEB_LIB_TOOL,
782                kcode_dev_tools::WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
783                "kennedy-test-web",
784                "extra.js",
785                kcode_dev_tools::ManagedSourceKind::WebLibrary,
786            ),
787            (
788                kcode_dev_tools::CREATE_RUST_BIN_TOOL,
789                kcode_dev_tools::OPEN_RUST_BIN_TOOL,
790                kcode_dev_tools::WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
791                "kennedy-test-bin",
792                "src/extra.rs",
793                kcode_dev_tools::ManagedSourceKind::RustBinary,
794            ),
795        ] {
796            let created = service
797                .execute(
798                    "create-session",
799                    create,
800                    serde_json::json!({"name":name}),
801                    Vec::new(),
802                )
803                .await
804                .unwrap();
805            assert_eq!(created.snapshot.unwrap().kind, kind);
806            let written = service
807                .execute(
808                    "create-session",
809                    write,
810                    serde_json::json!({
811                        "name":name,
812                        "path":path,
813                        "contents":"// Kennedy managed source\n",
814                    }),
815                    Vec::new(),
816                )
817                .await
818                .unwrap();
819            assert_eq!(written.snapshot.unwrap().kind, kind);
820
821            let open_result = service
822                .execute(
823                    "open-session",
824                    open,
825                    serde_json::json!({"name":name}),
826                    Vec::new(),
827                )
828                .await
829                .unwrap();
830            assert_eq!(open_result.snapshot.unwrap().kind, kind);
831        }
832        let asset = service
833            .execute(
834                "create-session",
835                kcode_dev_tools::ATTACH_OBJECT_WEB_LIB_TOOL,
836                serde_json::json!({
837                    "name":"kennedy-test-web",
838                    "path":"assets/fonts/display.woff2",
839                    "objectId":"pending:1",
840                }),
841                vec![vec![0, 159, 146, 150, 255]],
842            )
843            .await
844            .unwrap();
845        let snapshot = asset.snapshot.unwrap();
846        assert_eq!(
847            snapshot.kind,
848            kcode_dev_tools::ManagedSourceKind::WebLibrary
849        );
850        assert!(snapshot.text.contains("Asset: assets/fonts/display.woff2"));
851        assert!(snapshot.text.contains("Bytes: 5"));
852        assert!(!snapshot.text.contains("SHA-256:"));
853        assert_eq!(service.release("create-session").await.unwrap(), 3);
854        assert_eq!(service.release("open-session").await.unwrap(), 3);
855        drop(service);
856        std::fs::remove_dir_all(directory).unwrap();
857    }
858
859    #[test]
860    fn missing_optional_secret_disables_only_its_feature() {
861        let vault = CredentialVault::empty();
862        assert!(
863            resolve_optional_secret(&vault, "openai-api-key", "transcription")
864                .unwrap()
865                .is_none()
866        );
867        assert!(
868            resolve_optional_secret(&vault, "", "disabled")
869                .unwrap()
870                .is_none()
871        );
872    }
873
874    #[test]
875    fn required_secret_must_be_present() {
876        let mut vault = CredentialVault::empty();
877        let error =
878            resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "publication").unwrap_err();
879        assert!(error.to_string().contains(CRATES_IO_KEY_SECRET));
880
881        vault
882            .set(CRATES_IO_KEY_SECRET, "test-crates-io-key".into())
883            .unwrap();
884        assert_eq!(
885            resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "publication").unwrap(),
886            "test-crates-io-key"
887        );
888    }
889
890    #[test]
891    fn legacy_audio_database_is_copied_once_into_the_persistence_root() {
892        let directory = std::env::temp_dir().join(format!(
893            "kennedy-audio-migration-test-{}",
894            uuid::Uuid::new_v4()
895        ));
896        std::fs::create_dir(&directory).unwrap();
897        let legacy = directory.join("legacy.sqlite3");
898        let current = directory.join("audio-ingress/state.sqlite3");
899        let source = rusqlite::Connection::open(&legacy).unwrap();
900        source
901            .execute_batch("CREATE TABLE marker(value TEXT NOT NULL);")
902            .unwrap();
903        source
904            .execute("INSERT INTO marker(value) VALUES('legacy')", [])
905            .unwrap();
906        drop(source);
907
908        migrate_audio_ingress_database(&legacy, &current).unwrap();
909        let migrated = rusqlite::Connection::open(&current).unwrap();
910        let value: String = migrated
911            .query_row("SELECT value FROM marker", [], |row| row.get(0))
912            .unwrap();
913        assert_eq!(value, "legacy");
914        migrated
915            .execute("UPDATE marker SET value='current'", [])
916            .unwrap();
917        drop(migrated);
918
919        migrate_audio_ingress_database(&legacy, &current).unwrap();
920        let value: String = rusqlite::Connection::open(&current)
921            .unwrap()
922            .query_row("SELECT value FROM marker", [], |row| row.get(0))
923            .unwrap();
924        assert_eq!(value, "current");
925        std::fs::remove_dir_all(directory).unwrap();
926    }
927
928    #[tokio::test]
929    async fn occupied_kweb_address_prevents_server_from_opening_persistent_state() {
930        let directory =
931            std::env::temp_dir().join(format!("kennedy-server-lock-test-{}", uuid::Uuid::new_v4()));
932        std::fs::create_dir(&directory).unwrap();
933        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
934        let bind = listener.local_addr().unwrap().to_string();
935        let vault = directory.join("vault.age");
936        let kmap = directory.join("kweb");
937        let conversations = directory.join("conversations.sqlite3");
938        let telegram = directory.join("telegram.sqlite3");
939        let users = directory.join("users.sqlite3");
940        let tasks = directory.join("tasks.sqlite3");
941        let credits = directory.join("credits.sqlite3");
942        let audio = directory.join("audio.sqlite3");
943        let audio_media = directory.join("audio-media");
944        let args = Args {
945            vault_path: vault.clone(),
946            command: None,
947            kweb_bind: bind,
948            kweb_root: kmap.clone(),
949            conversation_history_database: conversations.clone(),
950            session_directory: directory.join("sessions"),
951            session_history_file: directory.join("session-history.txt"),
952            telegram_database: telegram.clone(),
953            user_database: users.clone(),
954            task_board_database: tasks.clone(),
955            credits_database: credits.clone(),
956            legacy_audio_ingress_database: audio.clone(),
957            audio_ingress_directory: audio_media.clone(),
958            intelligence_usage_directory: directory.join("intelligence-usage"),
959            rust_libs_root: directory.join("rust-libs"),
960            web_libs_root: directory.join("kcode-web-libs"),
961            web_libs_published_root: directory.join("kcode-web-libs-published"),
962            rust_bins_root: directory.join("kcode-rust-bins"),
963            rust_bin_artifacts_root: directory.join("kcode-rust-bin-artifacts"),
964            telegram_bootstrap_username: "@test".to_owned(),
965            telegram_max_voice_bytes: 1024,
966            audio_ingress_max_upload_bytes: 1024,
967        };
968
969        let error = run_server(args, vault.clone()).await.unwrap_err();
970        assert!(error.to_string().contains("binding Kweb listener"));
971        assert!(!vault.exists());
972        assert!(!kmap.exists());
973        assert!(!conversations.exists());
974        assert!(!telegram.exists());
975        assert!(!users.exists());
976        assert!(!tasks.exists());
977        assert!(!credits.exists());
978        assert!(!audio.exists());
979        assert!(!audio_media.exists());
980        std::fs::remove_dir_all(directory).unwrap();
981    }
982}