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 kcode_credential_vault::{CredentialVault, ExposeSecret, SecretString};
11use kcode_kennedy_cli::{Args, Command, SecretsCommand};
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#[tokio::main]
25pub async fn main() -> anyhow::Result<()> {
26    tracing_subscriber::fmt()
27        .with_env_filter(
28            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
29                "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()
30            }),
31        )
32        .init();
33    rustls::crypto::ring::default_provider()
34        .install_default()
35        .map_err(|_| anyhow::anyhow!("installing TLS crypto provider"))?;
36    let mut args = kcode_kennedy_cli::parse();
37    let vault_path = args.vault_path.clone();
38    match args.command.take() {
39        Some(Command::Secrets { command }) => {
40            let _maintenance_guard = tokio::net::TcpListener::bind(&args.kweb_bind)
41                .await
42                .with_context(|| {
43                    format!(
44                        "binding maintenance lock {}; stop the running Kennedy server before changing its credential vault",
45                        args.kweb_bind
46                    )
47                })?;
48            manage_secrets(command, &vault_path)
49        }
50        Some(Command::KmapSize) => {
51            let _maintenance_guard =
52                maintenance_guard(&args.kweb_bind, "measuring the Kweb").await?;
53            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
54            let vault = CredentialVault::unlock(&vault_path, passphrase)?;
55            let size = kcode_kmap_size::measure(&args.kweb_root, kweb_config(&vault)?)?;
56            println!("{}", kcode_kmap_size::render(&size));
57            Ok(())
58        }
59        None => run_server(args, vault_path).await,
60    }
61}
62
63async fn run_server(args: Args, vault_path: PathBuf) -> anyhow::Result<()> {
64    // Bind the public Kennedy address before opening any persistent state.
65    // Offline maintenance checks this address before copying the data tree.
66    let kweb_listener = tokio::net::TcpListener::bind(&args.kweb_bind)
67        .await
68        .with_context(|| format!("binding Kweb listener {}", args.kweb_bind))?;
69    ensure_runtime_parent_directories(&args, &vault_path)?;
70    let vault = if vault_path.exists() {
71        let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
72        CredentialVault::unlock(&vault_path, passphrase)?
73    } else {
74        tracing::warn!(path=%vault_path.display(), "Kennedy credential vault does not exist; secret-backed features are unavailable");
75        CredentialVault::empty()
76    };
77    let openai_api_key = resolve_optional_secret(
78        &vault,
79        OPENAI_API_KEY_SECRET,
80        "OpenAI transcription, media annotation, agents, and image generation/editing",
81    )?;
82    let gemini_api_key = resolve_optional_secret(
83        &vault,
84        GEMINI_API_KEY_SECRET,
85        "Gemini search, media annotation, agents, audio transcription, and image generation/editing",
86    )?;
87    let telegram_bot_token =
88        resolve_optional_secret(&vault, TELEGRAM_BOT_TOKEN_SECRET, "Telegram relay")?
89            .map(kcode_tg_kennedy_bot::BotToken::new)
90            .transpose()?;
91    let crates_io_key =
92        resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "Rust library publication")?;
93    let kweb_config = kweb_config(&vault)?;
94    let codex_catalog_cache =
95        kcode_codex_runtime::CatalogCache::new(kcode_codex_runtime::DEFAULT_CODEX_EXECUTABLE);
96    let (kmap, system_roots) =
97        kcode_kennedy_roots::open(&args.kweb_root, kweb_config, &args.user_database)?;
98    let (kmap_commands, kmap_command_runtime) =
99        kcode_kmap_command_lane::open(&args.user_database, kmap.clone())?;
100    let credits = kcode_credits::Credits::open(&args.credits_database)?;
101    let task_board = kcode_task_board::TaskBoard::open(&args.task_board_database, credits.clone())?;
102    let speech_classifier = SpeechClassifier::open(SPEECH_CLASSIFICATION_DATABASE_PATH)
103        .with_context(|| {
104            format!("opening speaker-classification database {SPEECH_CLASSIFICATION_DATABASE_PATH}")
105        })?;
106    let speech_classifier = Arc::new(speech_classifier);
107    let dev_tools = kcode_dev_tools::Service::open(kcode_dev_tools::Config {
108        rust_libraries_root: args.rust_libs_root.clone(),
109        web_libraries_root: args.web_libs_root.clone(),
110        web_publications_root: args.web_libs_published_root.clone(),
111        rust_binaries_root: args.rust_bins_root.clone(),
112        rust_binary_publications_root: args.rust_bin_artifacts_root.clone(),
113        crates_io_registry_token: crates_io_key,
114    })
115    .map_err(anyhow::Error::new)
116    .with_context(|| {
117        format!(
118            "opening managed Kcode development roots under {}",
119            args.rust_libs_root
120                .parent()
121                .unwrap_or(Path::new("."))
122                .display()
123        )
124    })?;
125    let web_publications_root = dev_tools.web_publications_root().to_path_buf();
126    let telegram_identity = std::sync::Arc::new(kcode_telegram_identity::Directory::open(
127        &args.user_database,
128        &args.telegram_bootstrap_username,
129    )?);
130    let history_service =
131        kcode_session_history::SessionHistory::open(kcode_session_history::Config {
132            directory: args.session_directory,
133            completed_list: args.session_history_file,
134            provider_cost_compatibility: Some(
135                kcode_intelligence_chatend::provider_cost_compatibility(),
136            ),
137        })?;
138    let (intelligence_service, intelligence_runtime) =
139        kcode_intelligence_router::open(kcode_intelligence_router::Config {
140            openai_api_key,
141            gemini_api_key,
142            codex_catalog_cache,
143            receipt_directory: args.intelligence_usage_directory,
144        })
145        .await?;
146    let agent_runtime = kcode_agent_runtime::AgentRuntime::new(intelligence_service.clone());
147    let telegram_runtime = kcode_tg_kennedy_bot::open(kcode_tg_kennedy_bot::Config {
148        database: args.telegram_database,
149        bot_token: telegram_bot_token,
150        identity_sink: telegram_identity.clone(),
151        max_voice_bytes: args.telegram_max_voice_bytes,
152    })
153    .await?;
154    let telegram_service = telegram_runtime.service();
155    let chunk_intelligence = intelligence_service.clone();
156    let transcribe_chunk: kcode_audio_ingress::AudioChunkCall = Arc::new(move |request| {
157        let intelligence = chunk_intelligence.clone();
158        Box::pin(async move {
159            let user = intelligence
160                .for_user(request.user_id)
161                .map_err(audio_intelligence_error)?;
162            let media = kcode_intelligence_router::Media::audio(
163                request.audio_ogg,
164                "audio-chunk.ogg",
165                "audio/ogg",
166            )
167            .map_err(audio_intelligence_error)?;
168            user.analyze_audio(kcode_intelligence_router::AudioAnalysisRequest {
169                operation: "transcribe_chunk".into(),
170                prompt: request.prompt,
171                model: request.model,
172                media,
173                schema: request.schema,
174                max_output_tokens: request.max_output_tokens,
175                temperature: None,
176                operation_id: uuid::Uuid::new_v4(),
177                parent_operation_id: None,
178            })
179            .await
180            .map(|response| response.value.text)
181            .map_err(audio_intelligence_error)
182        })
183    });
184    let text_intelligence = intelligence_service.clone();
185    let generate_text: kcode_audio_ingress::TextGenerationCall = Arc::new(move |request| {
186        let intelligence = text_intelligence.clone();
187        Box::pin(async move {
188            let reasoning_effort = match request.reasoning_effort.as_str() {
189                "xhigh" => kcode_intelligence_router::ReasoningEffort::XHigh,
190                _ => {
191                    return Err(kcode_audio_ingress::IntelligenceError::new(
192                        "AudioIngress requested an unsupported reasoning effort.",
193                        false,
194                    ));
195                }
196            };
197            let user = intelligence
198                .for_user(request.user_id)
199                .map_err(audio_intelligence_error)?;
200            user.generate_text(kcode_intelligence_router::TextGenerationRequest {
201                operation: request.operation,
202                prompt: request.prompt,
203                model: request.model,
204                reasoning_effort,
205                timeout: request.timeout,
206                operation_id: uuid::Uuid::new_v4(),
207                parent_operation_id: None,
208            })
209            .await
210            .map(|response| response.value.text)
211            .map_err(audio_intelligence_error)
212        })
213    });
214    let audio_transcriber =
215        kcode_audio_ingress::AudioTranscriber::new(transcribe_chunk, generate_text);
216    let audio = kcode_audio_ingress::AudioIngress::open(
217        &args.audio_ingress_directory,
218        audio_transcriber,
219        Arc::clone(&speech_classifier),
220    )
221    .await?;
222    let audio_coordinator = kcode_audio_session_ingress::Coordinator::new(
223        audio,
224        history_service.clone(),
225        kcode_audio_session_ingress::Config {
226            user_id: system_roots.user.to_string(),
227            effective_context_tokens: intelligence_runtime.context_window_tokens,
228        },
229    )?;
230    let http_router = kcode_http_api::router(kcode_http_api::Config {
231        kmap: kmap.clone(),
232        kmap_commands,
233        user_root_node_id: system_roots.user,
234        kennedy_root_node_id: system_roots.kennedy,
235        telegram: telegram_service.clone(),
236        session_history: history_service.clone(),
237        audio_ingress: audio_coordinator.clone(),
238        audio_max_upload_bytes: args.audio_ingress_max_upload_bytes,
239        task_board: task_board.clone(),
240        credits,
241        web_publications_root,
242    })?;
243    let orchestration_config = kcode_kennedy_orchestration::Config {
244        user_root_node_id: system_roots.user.to_string(),
245        kennedy_root_node_id: system_roots.kennedy.to_string(),
246        telegram_max_media_bytes: args.telegram_max_voice_bytes,
247        runtime_model: kcode_kennedy_orchestration::RuntimeModel::from_intelligence(
248            intelligence_runtime,
249        ),
250    };
251    let telegram_sessions = kcode_telegram_session_coordinator::Service::new(
252        telegram_service.clone(),
253        telegram_identity.clone(),
254    );
255    let session_service =
256        kcode_kennedy_sessions::Service::new(kcode_kennedy_sessions::Capabilities {
257            load_fixed_connections: args.fixed,
258            kmap: kmap.clone(),
259            intelligence: intelligence_service.clone(),
260            agents: agent_runtime,
261            history: history_service.clone(),
262            speech_classifier,
263            dev_tools: dev_tools.clone(),
264            telegram: telegram_sessions,
265        })
266        .with_task_board(task_board);
267    let orchestration_api = kcode_kennedy_orchestration::Api::new(
268        &orchestration_config,
269        kcode_kennedy_orchestration::LocalServices {
270            kmap: kmap.clone(),
271            intelligence: intelligence_service,
272            history: history_service.clone(),
273            audio: audio_coordinator,
274            directory: telegram_identity.clone(),
275            dev_tools,
276            telegram: telegram_service,
277        },
278    );
279    let orchestration_worker = kcode_kennedy_orchestration::build(
280        orchestration_config,
281        orchestration_api,
282        session_service,
283    );
284    let directory_roots = kcode_kennedy_roots::DirectoryRoots::new(
285        kmap,
286        telegram_identity,
287        args.telegram_bootstrap_username.clone(),
288        system_roots.user,
289        orchestration_worker.writer().clone(),
290    );
291    let telegram_session_runtime = Arc::new(kcode_kennedy_telegram_runtime::Runtime::new(
292        kcode_kennedy_telegram_runtime::Config {
293            telegram_max_media_bytes: args.telegram_max_voice_bytes,
294            telegram_web_user_handle: args.telegram_bootstrap_username,
295        },
296        orchestration_worker.clone(),
297        directory_roots,
298    ));
299    tokio::try_join!(
300        async {
301            kcode_http_api::serve(kweb_listener, http_router)
302                .await
303                .map_err(anyhow::Error::new)
304        },
305        telegram_runtime.run(),
306        kcode_kennedy_orchestration::run(orchestration_worker),
307        telegram_session_runtime.run(),
308        async { kmap_command_runtime.await.map_err(anyhow::Error::new) },
309    )?;
310    Ok(())
311}
312
313fn audio_intelligence_error(
314    error: kcode_intelligence_router::Error,
315) -> kcode_audio_ingress::IntelligenceError {
316    let retryable = error.retryable();
317    kcode_audio_ingress::IntelligenceError::new(error.message(), retryable)
318}
319
320fn ensure_runtime_parent_directories(args: &Args, vault_path: &Path) -> anyhow::Result<()> {
321    for path in [
322        vault_path,
323        &args.kweb_root,
324        &args.conversation_history_database,
325        &args.session_directory,
326        &args.session_history_file,
327        &args.telegram_database,
328        &args.user_database,
329        &args.task_board_database,
330        &args.credits_database,
331        Path::new(SPEECH_CLASSIFICATION_DATABASE_PATH),
332        &args.audio_ingress_directory,
333        &args.intelligence_usage_directory,
334        &args.rust_libs_root,
335        &args.web_libs_root,
336        &args.web_libs_published_root,
337        &args.rust_bins_root,
338        &args.rust_bin_artifacts_root,
339    ] {
340        let Some(parent) = path.parent().filter(|value| !value.as_os_str().is_empty()) else {
341            continue;
342        };
343        if parent.exists() {
344            continue;
345        }
346        let mut builder = std::fs::DirBuilder::new();
347        builder.recursive(true);
348        #[cfg(unix)]
349        {
350            use std::os::unix::fs::DirBuilderExt;
351            builder.mode(0o700);
352        }
353        builder
354            .create(parent)
355            .with_context(|| format!("creating runtime data directory {}", parent.display()))?;
356    }
357    Ok(())
358}
359
360pub async fn maintenance_guard(
361    bind: &str,
362    purpose: &str,
363) -> anyhow::Result<tokio::net::TcpListener> {
364    tokio::net::TcpListener::bind(bind).await.with_context(|| {
365        format!("binding maintenance lock {bind}; stop the running Kennedy server before {purpose}")
366    })
367}
368
369fn kweb_config(vault: &CredentialVault) -> anyhow::Result<KwebConfig> {
370    let encoded_key = resolve_required_secret(
371        vault,
372        KWEB_WRITER_SIGNING_KEY_SECRET,
373        "Kweb mutation signing",
374    )?;
375    let mut signing_key = Zeroizing::new([0_u8; 32]);
376    let decoded = hex::decode(encoded_key.trim())
377        .context("Kweb writer signing key must be 64 lowercase hexadecimal characters")?;
378    *signing_key = decoded
379        .try_into()
380        .map_err(|_| anyhow::anyhow!("Kweb writer signing key must decode to exactly 32 bytes"))?;
381    let encoded_writers =
382        resolve_required_secret(vault, KWEB_WRITERS_SECRET, "Kweb writer authorization")?;
383    let writers_by_priority = encoded_writers
384        .split(',')
385        .map(str::trim)
386        .filter(|value| !value.is_empty())
387        .map(WriterId::from_str)
388        .collect::<Result<Vec<_>, _>>()
389        .map_err(anyhow::Error::new)
390        .context("decoding the ordered Kweb writer whitelist")?;
391    anyhow::ensure!(
392        !writers_by_priority.is_empty(),
393        "the Kweb writer whitelist is empty"
394    );
395    Ok(KwebConfig {
396        signing_key: *signing_key,
397        writers_by_priority,
398        gossip: Arc::new(NoopGossip),
399    })
400}
401
402fn resolve_optional_secret(
403    vault: &CredentialVault,
404    configured_name: &str,
405    purpose: &str,
406) -> anyhow::Result<Option<String>> {
407    let name = configured_name.trim();
408    if name.is_empty() {
409        return Ok(None);
410    }
411    let secret = vault.secret(name)?;
412    if secret.is_none() {
413        tracing::warn!(secret_name=name, %purpose, "configured Kennedy secret is not present in the vault");
414    }
415    Ok(secret.map(|value| value.expose_secret().to_owned()))
416}
417
418fn resolve_required_secret(
419    vault: &CredentialVault,
420    configured_name: &str,
421    purpose: &str,
422) -> anyhow::Result<String> {
423    let name = configured_name.trim();
424    if name.is_empty() {
425        anyhow::bail!("{purpose} requires a configured Kennedy secret name");
426    }
427    vault
428        .secret(name)?
429        .map(|value| value.expose_secret().to_owned())
430        .with_context(|| {
431            format!(
432                "{purpose} requires Kennedy secret '{name}'; store it with `kennedy-server secrets set {name}`"
433            )
434        })
435}
436
437fn manage_secrets(command: SecretsCommand, vault_path: &Path) -> anyhow::Result<()> {
438    match command {
439        SecretsCommand::Set { name } => {
440            let (mut vault, passphrase) = unlock_for_edit(vault_path)?;
441            let value = prompt_confirmed_value(&format!("Value for {name}: "))?;
442            vault.set(&name, value)?;
443            vault.save(vault_path, &passphrase)?;
444            println!("Stored Kennedy secret '{name}'.");
445        }
446        SecretsCommand::Remove { name } => {
447            if !vault_path.exists() {
448                println!("No Kennedy credential vault exists yet.");
449                return Ok(());
450            }
451            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
452            let mut vault = CredentialVault::unlock(vault_path, passphrase.clone())?;
453            if vault.remove(&name)? {
454                vault.save(vault_path, &passphrase)?;
455                println!("Removed Kennedy secret '{name}'.");
456            } else {
457                println!("Kennedy secret '{name}' was not configured.");
458            }
459        }
460        SecretsCommand::List => {
461            if !vault_path.exists() {
462                println!("No Kennedy credential vault exists yet.");
463                return Ok(());
464            }
465            let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
466            let vault = CredentialVault::unlock(vault_path, passphrase)?;
467            let names = vault.names().collect::<Vec<_>>();
468            if names.is_empty() {
469                println!("The Kennedy credential vault contains no secrets.");
470            } else {
471                println!("Configured Kennedy secrets:");
472                for name in names {
473                    println!("- {name}");
474                }
475            }
476        }
477        SecretsCommand::ChangePassphrase => {
478            if !vault_path.exists() {
479                println!("No Kennedy credential vault exists yet.");
480                return Ok(());
481            }
482            let old = prompt_passphrase("Unlock Kennedy credential vault: ")?;
483            let vault = CredentialVault::unlock(vault_path, old)?;
484            let new = prompt_new_vault_passphrase()?;
485            vault.save(vault_path, &new)?;
486            println!("Changed the Kennedy credential vault passphrase.");
487        }
488    }
489    Ok(())
490}
491
492fn unlock_for_edit(path: &Path) -> anyhow::Result<(CredentialVault, SecretString)> {
493    if path.exists() {
494        let passphrase = prompt_passphrase("Unlock Kennedy credential vault: ")?;
495        let vault = CredentialVault::unlock(path, passphrase.clone())?;
496        Ok((vault, passphrase))
497    } else {
498        let passphrase = prompt_new_vault_passphrase()?;
499        Ok((CredentialVault::empty(), passphrase))
500    }
501}
502
503fn prompt_passphrase(prompt: &str) -> anyhow::Result<SecretString> {
504    let mut value = rpassword::prompt_password(prompt)?;
505    if value.is_empty() {
506        value.zeroize();
507        anyhow::bail!("the credential vault passphrase cannot be empty");
508    }
509    Ok(SecretString::from(value))
510}
511
512fn prompt_new_vault_passphrase() -> anyhow::Result<SecretString> {
513    let mut first = rpassword::prompt_password("Create Kennedy credential vault passphrase: ")?;
514    let mut second = rpassword::prompt_password("Confirm Kennedy credential vault passphrase: ")?;
515    if first.is_empty() || first != second {
516        first.zeroize();
517        second.zeroize();
518        anyhow::bail!("credential vault passphrases were empty or did not match");
519    }
520    second.zeroize();
521    Ok(SecretString::from(first))
522}
523
524fn prompt_confirmed_value(prompt: &str) -> anyhow::Result<String> {
525    let mut first = rpassword::prompt_password(prompt)?;
526    let mut second = rpassword::prompt_password("Confirm secret value: ")?;
527    if first.is_empty() || first != second {
528        first.zeroize();
529        second.zeroize();
530        anyhow::bail!("secret values were empty or did not match");
531    }
532    second.zeroize();
533    Ok(first)
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn secret_names_are_stable_code_defaults() {
542        assert_eq!(OPENAI_API_KEY_SECRET, "openai-api-key");
543        assert_eq!(GEMINI_API_KEY_SECRET, "gemini-api-key");
544        assert_eq!(TELEGRAM_BOT_TOKEN_SECRET, "telegram-bot-token");
545        assert_eq!(CRATES_IO_KEY_SECRET, "cratesio-key");
546        assert_eq!(KWEB_WRITER_SIGNING_KEY_SECRET, "kweb-writer-signing-key");
547        assert_eq!(KWEB_WRITERS_SECRET, "kweb-writers-by-priority");
548    }
549
550    #[test]
551    fn native_orchestration_remains_a_rust_backend_concern() {
552        assert_eq!(
553            std::any::type_name::<kcode_kennedy_orchestration::Session>(),
554            "kcode_kennedy_sessions::Session"
555        );
556    }
557
558    #[tokio::test]
559    async fn unified_dev_tools_service_opens_all_roots_and_routes_three_source_kinds() {
560        let directory = std::env::temp_dir().join(format!(
561            "kennedy-dev-tools-open-test-{}",
562            uuid::Uuid::new_v4()
563        ));
564        let rust_libraries = directory.join("kcode-rust-libs");
565        let web_libraries = directory.join("kcode-web-libs");
566        let web_publications = directory.join("kcode-web-libs-published");
567        let rust_binaries = directory.join("kcode-rust-bins");
568        let rust_binary_artifacts = directory.join("kcode-rust-bin-artifacts");
569        let service = kcode_dev_tools::Service::open(kcode_dev_tools::Config {
570            rust_libraries_root: rust_libraries.clone(),
571            web_libraries_root: web_libraries.clone(),
572            web_publications_root: web_publications.clone(),
573            rust_binaries_root: rust_binaries.clone(),
574            rust_binary_publications_root: rust_binary_artifacts.clone(),
575            crates_io_registry_token: "test-token".into(),
576        })
577        .unwrap();
578
579        assert_eq!(
580            service.web_libraries_root(),
581            std::fs::canonicalize(&web_libraries).unwrap()
582        );
583        assert_eq!(
584            service.web_publications_root(),
585            std::fs::canonicalize(&web_publications).unwrap()
586        );
587        for path in [
588            rust_libraries,
589            web_libraries,
590            web_publications,
591            rust_binaries,
592            rust_binary_artifacts,
593        ] {
594            assert!(
595                path.is_dir(),
596                "managed root was not created: {}",
597                path.display()
598            );
599        }
600        for (create, open, write, name, path, kind) in [
601            (
602                kcode_dev_tools::CREATE_RUST_LIB_TOOL,
603                kcode_dev_tools::OPEN_RUST_LIB_TOOL,
604                kcode_dev_tools::WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
605                "kennedy-test-lib",
606                "src/extra.rs",
607                kcode_dev_tools::ManagedSourceKind::RustLibrary,
608            ),
609            (
610                kcode_dev_tools::CREATE_WEB_LIB_TOOL,
611                kcode_dev_tools::OPEN_WEB_LIB_TOOL,
612                kcode_dev_tools::WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
613                "kennedy-test-web",
614                "extra.js",
615                kcode_dev_tools::ManagedSourceKind::WebLibrary,
616            ),
617            (
618                kcode_dev_tools::CREATE_RUST_BIN_TOOL,
619                kcode_dev_tools::OPEN_RUST_BIN_TOOL,
620                kcode_dev_tools::WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
621                "kennedy-test-bin",
622                "src/extra.rs",
623                kcode_dev_tools::ManagedSourceKind::RustBinary,
624            ),
625        ] {
626            let created = service
627                .execute(
628                    "create-session",
629                    create,
630                    serde_json::json!({"name":name}),
631                    Vec::new(),
632                )
633                .await
634                .unwrap();
635            assert_eq!(created.snapshot.unwrap().kind, kind);
636            let written = service
637                .execute(
638                    "create-session",
639                    write,
640                    serde_json::json!({
641                        "name":name,
642                        "path":path,
643                        "contents":"// Kennedy managed source\n",
644                    }),
645                    Vec::new(),
646                )
647                .await
648                .unwrap();
649            assert_eq!(written.snapshot.unwrap().kind, kind);
650
651            let open_result = service
652                .execute(
653                    "open-session",
654                    open,
655                    serde_json::json!({"name":name}),
656                    Vec::new(),
657                )
658                .await
659                .unwrap();
660            assert_eq!(open_result.snapshot.unwrap().kind, kind);
661        }
662        let asset = service
663            .execute(
664                "create-session",
665                kcode_dev_tools::ATTACH_OBJECT_WEB_LIB_TOOL,
666                serde_json::json!({
667                    "name":"kennedy-test-web",
668                    "path":"assets/fonts/display.woff2",
669                    "objectId":"pending:1",
670                }),
671                vec![vec![0, 159, 146, 150, 255]],
672            )
673            .await
674            .unwrap();
675        let snapshot = asset.snapshot.unwrap();
676        assert_eq!(
677            snapshot.kind,
678            kcode_dev_tools::ManagedSourceKind::WebLibrary
679        );
680        assert!(snapshot.text.contains("Asset: assets/fonts/display.woff2"));
681        assert!(snapshot.text.contains("Bytes: 5"));
682        assert!(!snapshot.text.contains("SHA-256:"));
683        assert_eq!(service.release("create-session").await.unwrap(), 3);
684        assert_eq!(service.release("open-session").await.unwrap(), 3);
685        drop(service);
686        std::fs::remove_dir_all(directory).unwrap();
687    }
688
689    #[test]
690    fn missing_optional_secret_disables_only_its_feature() {
691        let vault = CredentialVault::empty();
692        assert!(
693            resolve_optional_secret(&vault, "openai-api-key", "transcription")
694                .unwrap()
695                .is_none()
696        );
697        assert!(
698            resolve_optional_secret(&vault, "", "disabled")
699                .unwrap()
700                .is_none()
701        );
702    }
703
704    #[test]
705    fn required_secret_must_be_present() {
706        let mut vault = CredentialVault::empty();
707        let error =
708            resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "publication").unwrap_err();
709        assert!(error.to_string().contains(CRATES_IO_KEY_SECRET));
710
711        vault
712            .set(CRATES_IO_KEY_SECRET, "test-crates-io-key".into())
713            .unwrap();
714        assert_eq!(
715            resolve_required_secret(&vault, CRATES_IO_KEY_SECRET, "publication").unwrap(),
716            "test-crates-io-key"
717        );
718    }
719
720    #[tokio::test]
721    async fn occupied_kweb_address_prevents_server_from_opening_persistent_state() {
722        let directory =
723            std::env::temp_dir().join(format!("kennedy-server-lock-test-{}", uuid::Uuid::new_v4()));
724        std::fs::create_dir(&directory).unwrap();
725        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
726        let bind = listener.local_addr().unwrap().to_string();
727        let vault = directory.join("vault.age");
728        let kmap = directory.join("kweb");
729        let conversations = directory.join("conversations.sqlite3");
730        let telegram = directory.join("telegram.sqlite3");
731        let users = directory.join("users.sqlite3");
732        let tasks = directory.join("tasks.sqlite3");
733        let credits = directory.join("credits.sqlite3");
734        let audio_media = directory.join("audio-media");
735        let args = Args {
736            vault_path: vault.clone(),
737            command: None,
738            kweb_bind: bind,
739            kweb_root: kmap.clone(),
740            conversation_history_database: conversations.clone(),
741            session_directory: directory.join("sessions"),
742            session_history_file: directory.join("session-history.txt"),
743            telegram_database: telegram.clone(),
744            user_database: users.clone(),
745            task_board_database: tasks.clone(),
746            credits_database: credits.clone(),
747            audio_ingress_directory: audio_media.clone(),
748            intelligence_usage_directory: directory.join("intelligence-usage"),
749            rust_libs_root: directory.join("rust-libs"),
750            web_libs_root: directory.join("kcode-web-libs"),
751            web_libs_published_root: directory.join("kcode-web-libs-published"),
752            rust_bins_root: directory.join("kcode-rust-bins"),
753            rust_bin_artifacts_root: directory.join("kcode-rust-bin-artifacts"),
754            telegram_bootstrap_username: "@test".to_owned(),
755            telegram_max_voice_bytes: 1024,
756            audio_ingress_max_upload_bytes: 1024,
757            fixed: false,
758        };
759
760        let error = run_server(args, vault.clone()).await.unwrap_err();
761        assert!(error.to_string().contains("binding Kweb listener"));
762        assert!(!vault.exists());
763        assert!(!kmap.exists());
764        assert!(!conversations.exists());
765        assert!(!telegram.exists());
766        assert!(!users.exists());
767        assert!(!tasks.exists());
768        assert!(!credits.exists());
769        assert!(!audio_media.exists());
770        std::fs::remove_dir_all(directory).unwrap();
771    }
772}