Skip to main content

kcode_k1_daemon_lib/
lib.rs

1#![doc = include_str!("../Documentation.md")]
2
3use kcode_gemini_3_1_pro::Gemini31Pro;
4use kcode_k1_access::K1Access;
5use kcode_k1_access_full_audio::K1AccessFullAudio;
6use kcode_k1_access_persons::K1AccessPersons;
7use kcode_k1_access_profiles::K1AccessProfiles;
8use kcode_k1_accounting::Accounting;
9use kcode_k1_accounts::K1Accounts;
10use kcode_k1_audio_classification::AudioClassification;
11use kcode_k1_chat_service::K1ChatService;
12use kcode_k1_codex_adapter::{Adapter as CodexAdapter, Error as CodexAdapterError};
13use kcode_k1_daemon_files::DaemonFiles;
14use kcode_k1_daemon_http_boundary::{
15    Boundary, PUBLIC_ORIGIN, api_not_found, warn_if_slow, write_readiness,
16};
17use kcode_k1_daemon_provider_config::{
18    CODEX_EXECUTABLE_ENV, audio_models, codex_configs, codex_executable, resolve_ffmpeg,
19};
20use kcode_k1_full_audio::K1FullAudio;
21use kcode_k1_groups::K1Groups;
22use kcode_k1_http::{Config as HttpConfig, K1Http};
23use kcode_k1_http_accounts::K1HttpAccounts;
24use kcode_k1_http_people::K1HttpPeople;
25use kcode_k1_http_replay::{ReplayConfig, ReplayWindow};
26use kcode_k1_invites::K1Invites;
27use kcode_k1_objects::K1Objects;
28use kcode_k1_peering::K1Peering;
29use kcode_k1_persons::K1Persons;
30use kcode_k1_txn_ordering::K1TxnOrdering;
31use kcode_k1_users::K1Users;
32use kcode_k1_vault::{ExposeSecret, K1Vault, SecretString};
33use kcode_speaker_v3_analysis::Analyzer;
34use std::fmt;
35use std::path::{Path, PathBuf};
36use std::process::ExitCode;
37use std::sync::Arc;
38use std::time::Instant;
39
40const INVITE_LINK_URL: &str = "http://localhost:4321/lib/kcode-k1-ui/*/account.html";
41const GEMINI_API_KEY: &str = "gemini-api-key";
42
43struct Prepared {
44    boundary: Boundary,
45    unused_invites: usize,
46    vault: Arc<K1Vault>,
47    chat: K1ChatService,
48}
49
50enum StartupError {
51    Generic,
52    CodexAdapter(CodexAdapterError),
53    Chat(String),
54}
55
56impl From<()> for StartupError {
57    fn from((): ()) -> Self {
58        Self::Generic
59    }
60}
61
62impl fmt::Display for StartupError {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::Generic => formatter.write_str("kcode-k1-daemon: startup failed"),
66            Self::CodexAdapter(error) => {
67                write!(formatter, "kcode-k1-daemon: startup failed: {error}")
68            }
69            Self::Chat(child) => {
70                write!(formatter, "kcode-k1-daemon: startup failed: {child}")
71            }
72        }
73    }
74}
75
76pub fn run(k1_root: PathBuf) -> ExitCode {
77    let runtime = match tokio::runtime::Builder::new_multi_thread()
78        .enable_all()
79        .build()
80    {
81        Ok(runtime) => runtime,
82        Err(_) => {
83            eprintln!("kcode-k1-daemon: startup failed");
84            return ExitCode::from(1);
85        }
86    };
87    let passphrase = match rpassword::prompt_password("Unlock K1 vault: ") {
88        Ok(passphrase) => match protect_passphrase(passphrase) {
89            Ok(passphrase) => passphrase,
90            Err(()) => {
91                eprintln!("kcode-k1-daemon: startup failed");
92                return ExitCode::from(1);
93            }
94        },
95        Err(_) => {
96            eprintln!("kcode-k1-daemon: startup failed");
97            return ExitCode::from(1);
98        }
99    };
100    runtime.block_on(run_async(k1_root, passphrase))
101}
102
103fn protect_passphrase(passphrase: String) -> Result<SecretString, ()> {
104    (!passphrase.is_empty())
105        .then(|| SecretString::from(passphrase))
106        .ok_or(())
107}
108
109async fn run_async(k1_root: PathBuf, passphrase: SecretString) -> ExitCode {
110    let started = Instant::now();
111    let prepared = match startup(k1_root, passphrase).await {
112        Ok(prepared) => prepared,
113        Err(error) => {
114            warn_if_slow(started.elapsed(), "error");
115            eprintln!("{error}");
116            return ExitCode::from(1);
117        }
118    };
119    let elapsed = started.elapsed();
120    if write_readiness(prepared.unused_invites).is_err() {
121        warn_if_slow(elapsed, "error");
122        eprintln!("kcode-k1-daemon: startup failed");
123        return ExitCode::from(1);
124    }
125    warn_if_slow(elapsed, "ready");
126    let Prepared {
127        boundary,
128        vault,
129        chat,
130        ..
131    } = prepared;
132    let result = boundary.serve().await;
133    drop((chat, vault));
134    match result {
135        Ok(()) => ExitCode::SUCCESS,
136        Err(()) => {
137            eprintln!("kcode-k1-daemon: listener failed");
138            ExitCode::from(1)
139        }
140    }
141}
142
143async fn startup(k1_root: PathBuf, passphrase: SecretString) -> Result<Prepared, StartupError> {
144    let state_root = state_root(&k1_root);
145    let files = DaemonFiles::open(&state_root).map_err(|_| ())?;
146    let ordering = Arc::new(K1TxnOrdering::open(&state_root.join("ordering")).map_err(|_| ())?);
147    let peering = Arc::new(
148        K1Peering::open(&state_root.join("peering"), Arc::clone(&ordering)).map_err(|_| ())?,
149    );
150    let vault = open_vault(
151        &state_root,
152        passphrase,
153        Arc::clone(&ordering),
154        Arc::clone(&peering),
155    )?;
156    let persons = Arc::new(
157        K1Persons::open(
158            &state_root.join("persons"),
159            Arc::clone(&ordering),
160            Arc::clone(&peering),
161        )
162        .map_err(|_| ())?,
163    );
164    let invites = Arc::new(
165        K1Invites::open(
166            &state_root.join("invites"),
167            Arc::clone(&ordering),
168            Arc::clone(&peering),
169        )
170        .map_err(|_| ())?,
171    );
172    let accounts = Arc::new(K1Accounts::open(Arc::clone(&invites)).map_err(|_| ())?);
173    let users = Arc::new(K1Users::new(Arc::clone(&accounts), Arc::clone(&persons)));
174    let groups = Arc::new(
175        K1Groups::open(
176            &state_root.join("groups"),
177            Arc::clone(&ordering),
178            Arc::clone(&peering),
179        )
180        .map_err(|_| ())?,
181    );
182    let profiles = Arc::new(
183        K1AccessProfiles::open(
184            &state_root.join("access-profiles"),
185            Arc::clone(&ordering),
186            Arc::clone(&peering),
187        )
188        .map_err(|_| ())?,
189    );
190    let gemini_key = vault.secret(GEMINI_API_KEY).map_err(|_| ())?.ok_or(())?;
191    let gemini = Gemini31Pro::new(
192        gemini_key.expose_secret().to_owned(),
193        Accounting::new(),
194        std::time::Duration::from_secs(30 * 60),
195    )
196    .map_err(|_| ())?;
197    let executable = codex_executable(std::env::var_os(CODEX_EXECUTABLE_ENV));
198    let working_directory = std::env::current_dir()
199        .map_err(|_| ())?
200        .to_string_lossy()
201        .into_owned();
202    let (audio_config, chat_config) = codex_configs(executable, working_directory);
203    let audio_codex_adapter = CodexAdapter::open(audio_config)
204        .await
205        .map_err(StartupError::CodexAdapter)?;
206    let chat_codex_adapter = audio_codex_adapter
207        .with_config(chat_config)
208        .map_err(StartupError::CodexAdapter)?;
209    let analyzer = Analyzer::from_codex_adapter(gemini, audio_codex_adapter);
210    let objects =
211        Arc::new(K1Objects::open(Arc::clone(&ordering), Arc::clone(&peering)).map_err(|_| ())?);
212    let classification = Arc::new(
213        AudioClassification::open(
214            &state_root.join("audio-classification"),
215            Arc::clone(&ordering),
216            Arc::clone(&peering),
217            Arc::clone(&objects),
218            analyzer,
219        )
220        .map_err(|_| ())?,
221    );
222    let full_audio = Arc::new(
223        K1FullAudio::open(
224            resolve_ffmpeg()?,
225            Arc::clone(&objects),
226            Arc::clone(&classification),
227        )
228        .map_err(|_| ())?,
229    );
230    let access = Arc::new(
231        K1Access::open(
232            &state_root.join("access"),
233            Arc::clone(&ordering),
234            Arc::clone(&peering),
235            Arc::clone(&groups),
236        )
237        .map_err(|_| ())?,
238    );
239    let chat = K1ChatService::open(
240        &state_root.join("chat"),
241        Arc::clone(&ordering),
242        Arc::clone(&peering),
243        Arc::clone(&access),
244        Arc::clone(&profiles),
245        chat_codex_adapter,
246    )
247    .map_err(StartupError::Chat)?;
248    let access_persons = Arc::new(
249        K1AccessPersons::open(
250            Arc::clone(&access),
251            Arc::clone(&profiles),
252            Arc::clone(&persons),
253        )
254        .map_err(|_| ())?,
255    );
256    let models = audio_models();
257    let audio = Arc::new(
258        K1AccessFullAudio::open_for_models(
259            Arc::clone(&access),
260            Arc::clone(&profiles),
261            full_audio,
262            classification,
263            Arc::clone(&groups),
264            models.to_vec(),
265        )
266        .map_err(|_| ())?,
267    );
268    let replay = ReplayWindow::open(ReplayConfig {
269        epoch_file: files.replay_epoch_path().to_owned(),
270        max_nonces_per_epoch: usize::MAX,
271    })
272    .await
273    .map_err(|_| ())?;
274    let unused_invites = kcode_k1_daemon_invite_stock::reconcile(
275        &invites,
276        files.invite_links_path(),
277        INVITE_LINK_URL,
278    )
279    .map_err(|_| ())?;
280    if unused_invites < 100 {
281        return Err(().into());
282    }
283    let adapter = K1HttpAccounts::new(
284        Arc::clone(&accounts),
285        Arc::clone(&invites),
286        Arc::clone(&users),
287    );
288    let people = K1HttpPeople::new(accounts, users, groups, profiles);
289    let http = K1Http::new(
290        HttpConfig {
291            server_id: files.server_id().to_owned(),
292            public_origin: PUBLIC_ORIGIN.to_owned(),
293            max_body_bytes: usize::MAX,
294        },
295        replay,
296        adapter.identity_provider(),
297    )
298    .map_err(|_| ())?;
299    let person_routes =
300        kcode_k1_http_persons::authenticated_routes(access_persons, access, models[0])
301            .map_err(|_| ())?;
302    let authenticated = adapter
303        .authenticated_routes()
304        .merge(people.authenticated_routes())
305        .merge(kcode_k1_http_audio::authenticated_routes(audio))
306        .merge(person_routes)
307        .fallback(api_not_found);
308    let api = http.router(
309        adapter.registration_endpoint(),
310        kcode_k1_terms::endpoint(),
311        authenticated,
312    );
313    let boundary = Boundary::bind(api, files.server_id().to_owned())
314        .await
315        .map_err(|_| ())?;
316    Ok(Prepared {
317        boundary,
318        unused_invites,
319        vault,
320        chat,
321    })
322}
323
324fn open_vault(
325    state_root: &Path,
326    passphrase: SecretString,
327    ordering: Arc<K1TxnOrdering>,
328    peering: Arc<K1Peering>,
329) -> Result<Arc<K1Vault>, ()> {
330    K1Vault::open(&state_root.join("vault"), passphrase, ordering, peering)
331        .map(Arc::new)
332        .map_err(|_| ())
333}
334
335fn state_root(k1_root: &Path) -> PathBuf {
336    k1_root.join("state")
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn public_operation_and_state_root_are_fixed() {
345        let _: fn(PathBuf) -> ExitCode = run;
346        assert_eq!(
347            state_root(Path::new("/trusted/k1")),
348            PathBuf::from("/trusted/k1/state")
349        );
350    }
351
352    #[test]
353    fn accepted_passphrase_boundary_is_strict_and_protected() {
354        assert!(protect_passphrase(String::new()).is_err());
355        let text = "conspicuous-fake-passphrase-never-real";
356        let protected = protect_passphrase(text.to_owned()).unwrap();
357        assert!(!format!("{protected:?}").contains(text));
358    }
359
360    #[test]
361    fn vault_composition_persists_at_the_fixed_path() {
362        let root =
363            std::env::temp_dir().join(format!("kcode-k1-daemon-vault-test-{}", std::process::id()));
364        let _ = std::fs::remove_dir_all(&root);
365        let state = state_root(&root);
366        assert_eq!(state.join("vault"), root.join("state/vault"));
367        let parts = || {
368            let ordering = Arc::new(K1TxnOrdering::open(&state.join("ordering")).unwrap());
369            let peering =
370                Arc::new(K1Peering::open(&state.join("peering"), ordering.clone()).unwrap());
371            (ordering, peering)
372        };
373        let password = || SecretString::from("fake-test-password-never-real");
374        let (ordering, peering) = parts();
375        let vault = open_vault(&state, password(), ordering.clone(), peering.clone()).unwrap();
376        vault
377            .set(
378                "fake-provider-secret",
379                SecretString::from("conspicuous-fake-value-never-real"),
380            )
381            .unwrap();
382        drop((vault, peering, ordering));
383        let (ordering, peering) = parts();
384        let vault = open_vault(&state, password(), ordering.clone(), peering.clone()).unwrap();
385        drop((vault, peering, ordering));
386        let (ordering, peering) = parts();
387        assert!(
388            open_vault(
389                &state,
390                SecretString::from("wrong-fake-password-never-real"),
391                ordering,
392                peering
393            )
394            .is_err()
395        );
396        std::fs::remove_dir_all(root).unwrap();
397    }
398
399    #[test]
400    fn fixed_provider_key_and_origins_remain_exact_and_distinct() {
401        assert_eq!(GEMINI_API_KEY, "gemini-api-key");
402        assert_eq!(
403            INVITE_LINK_URL,
404            "http://localhost:4321/lib/kcode-k1-ui/*/account.html"
405        );
406        assert_eq!(PUBLIC_ORIGIN, "http://localhost:4450");
407        assert_ne!(INVITE_LINK_URL, PUBLIC_ORIGIN);
408    }
409
410    #[test]
411    fn startup_error_rendering_preserves_safe_adapter_and_chat_messages() {
412        assert_eq!(
413            StartupError::from(()).to_string(),
414            "kcode-k1-daemon: startup failed"
415        );
416        let error = CodexAdapterError {
417            kind: kcode_k1_codex_adapter::ErrorKind::Unavailable,
418            message: "safe adapter display".to_owned(),
419            diagnostics: b"RAW_SECRET_DIAGNOSTIC".to_vec(),
420        };
421        let rendered = StartupError::CodexAdapter(error).to_string();
422        assert_eq!(
423            rendered,
424            "kcode-k1-daemon: startup failed: safe adapter display"
425        );
426        assert!(!rendered.contains("RAW_SECRET_DIAGNOSTIC"));
427        let rendered =
428            StartupError::Chat("open chat service: safe child failure".to_owned()).to_string();
429        assert_eq!(
430            rendered,
431            "kcode-k1-daemon: startup failed: open chat service: safe child failure"
432        );
433    }
434
435    #[test]
436    fn selected_composition_dependencies_and_constructor_are_compatible() {
437        const MANIFEST: &str = include_str!("../Cargo.toml");
438        for selected in [
439            "kcode-k1-access-full-audio = \"0.7.3\"",
440            "kcode-k1-audio-classification = \"0.5.5\"",
441            "kcode-k1-chat-service = \"0.2.0\"",
442            "kcode-k1-codex-adapter = \"0.5.0\"",
443            "kcode-k1-daemon-http-boundary = \"0.1.0\"",
444            "kcode-k1-daemon-provider-config = \"0.1.0\"",
445            "kcode-k1-full-audio = \"0.3.6\"",
446            "kcode-k1-http-audio = \"0.1.4\"",
447            "kcode-speaker-v3-analysis = { version = \"0.3.4\", default-features = false, features = [\"adapter-providers\"] }",
448        ] {
449            assert!(MANIFEST.contains(selected));
450        }
451        assert!(!MANIFEST.lines().any(|line| {
452            line.trim_start()
453                .starts_with("kcode-speaker-v3-terra-analysis ")
454        }));
455        fn require_constructor(_: fn(Gemini31Pro, CodexAdapter) -> Analyzer) {}
456        require_constructor(Analyzer::from_codex_adapter);
457    }
458}