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 Secrets {
95 #[command(subcommand)]
96 command: SecretsCommand,
97 },
98 KmapSize,
100}
101
102#[derive(Subcommand, Debug)]
103enum SecretsCommand {
104 Set { name: String },
106 Remove { name: String },
108 List,
110 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 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, ¤t).unwrap();
897 let migrated = rusqlite::Connection::open(¤t).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, ¤t).unwrap();
908 let value: String = rusqlite::Connection::open(¤t)
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}