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