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