1use crate::backlog;
9use crate::cli::{Cli, Command, GrantCommand, QuestionCommand, RevisionCommand, TicketCommand};
10use crate::output::{self, ansi};
11use crate::planning_tui::PlanningOutcome;
12use crate::tail::{self, EventRenderer};
13use anyhow::{anyhow, bail, Context, Result};
14use kranz_engine::backend::AgentBackend;
15use kranz_engine::backend_claude::ClaudeBackend;
16use kranz_engine::config;
17use kranz_engine::control;
18use kranz_engine::corpus_export;
19use kranz_engine::cost;
20use kranz_engine::event_log::{EventLog, LockForce};
21use kranz_engine::mission_catalog;
22use kranz_engine::orchestrator::{MissionEngine, PlanRequest};
23use kranz_engine::paths::MissionPaths;
24use kranz_engine::reducer;
25use kranz_engine::trace_export;
26use kranz_engine::types::{ControlCommand, MissionConfig, MissionState, MissionStatus};
27use std::io::{IsTerminal, Write};
28use std::path::{Path, PathBuf};
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::Arc;
31use std::time::{Duration, SystemTime};
32
33pub async fn run_cli(cli: Cli) -> Result<i32> {
36 let lock_force = cli.lock_force();
37 let repo = match cli.repo {
38 Some(repo) => repo,
39 None => std::env::current_dir().context("cannot determine the current directory")?,
40 };
41 if cli.dangerously_allow_all {
42 print_danger_banner();
43 }
44
45 match cli.command {
46 Command::Licenses => {
47 let mut out = std::io::stdout().lock();
48 out.write_all(include_bytes!("../LICENSE"))?;
49 out.write_all(b"\n\n")?;
50 out.write_all(include_bytes!("../assets/THIRD_PARTY_NOTICES.txt"))?;
51 out.write_all(b"\n\nEmbedded dashboard dependency notices\n")?;
52 out.write_all(include_bytes!(
53 "../assets/dashboard/dist/THIRD_PARTY_NOTICES.txt"
54 ))?;
55 Ok(0)
56 }
57 Command::Init {
58 gates,
59 register,
60 id,
61 display_name,
62 } => {
63 let options = crate::init::InitOptions {
64 gates,
65 registration: register.then_some(crate::init::Registration { id, display_name }),
66 global_config: kranz_engine::paths::global_config(),
67 };
68 let report = crate::init::initialize(&repo, &options)?;
69 print!("{}", crate::init::render(&report));
70 Ok(0)
71 }
72 Command::Plan { goal } => {
73 let cfg = load_config(&repo, cli.dangerously_allow_all)?;
74 cmd_plan(repo, goal, cfg, cli.mission.as_deref(), lock_force)
75 .await
76 .map_err(augment_limit_hint)
77 }
78 Command::Run => {
79 let mission = select_mission(&repo, cli.mission.as_deref())?;
80 cmd_run(repo, mission, lock_force, cli.dangerously_allow_all)
81 .await
82 .map_err(augment_limit_hint)
83 }
84 Command::Status { json } => {
85 let mission = select_mission(&repo, cli.mission.as_deref())?;
86 let state = load_state(&repo, &mission)?;
87 if json {
88 println!("{}", serde_json::to_string_pretty(&state)?);
89 } else {
90 print!("{}", output::render_status(&state));
91 }
92 Ok(0)
93 }
94 Command::SandboxProbe { json } => {
95 let report = kranz_engine::sandbox_windows::probe();
96 if json {
97 println!("{}", serde_json::to_string_pretty(&report)?);
98 } else {
99 print!("{}", report.render_text());
100 }
101 Ok(0)
102 }
103 Command::SandboxPrepare { targets } => {
104 for target in targets {
105 eprintln!(
106 "Preparing AppContainer host metadata: target={}",
107 target.display()
108 );
109 let changed = kranz_engine::sandbox_windows::prepare_appcontainer_host(&target)
110 .map_err(anyhow::Error::msg)?;
111 let result = if changed {
112 "applied"
113 } else {
114 "already prepared"
115 };
116 println!(
117 "AppContainer host preparation {result}: target={} mask=0x00120088 inheritance=none",
118 target.display()
119 );
120 }
121 eprintln!("Preparing AppContainer profile-parent metadata (derived from USERPROFILE)");
125 let (profile_parent, profile_changed) =
126 kranz_engine::sandbox_windows::prepare_appcontainer_profile_parent()
127 .map_err(anyhow::Error::msg)?;
128 let profile_result = if profile_changed {
129 "applied"
130 } else {
131 "already prepared"
132 };
133 println!(
134 "AppContainer profile-parent preparation {profile_result}: target={} mask=0x00120088 inheritance=none",
135 profile_parent.display()
136 );
137 eprintln!("Preparing AppContainer null-device metadata: target=\\Device\\Null");
138 kranz_engine::sandbox_windows::prepare_appcontainer_null_device()
139 .map_err(anyhow::Error::msg)?;
140 println!(
141 "AppContainer null-device preparation applied: target=\\Device\\Null inheritance=none"
142 );
143 Ok(0)
144 }
145 Command::Outcomes {
146 json,
147 all,
148 window_days,
149 } => {
150 if all {
151 let config = kranz_engine::paths::global_config()
154 .ok_or_else(|| anyhow::anyhow!("cannot locate the home directory"))?;
155 let report =
156 crate::merged_costs::assess_all(&config, window_days, chrono::Utc::now());
157 if json {
158 println!("{}", serde_json::to_string_pretty(&report)?);
159 } else {
160 print!("{}", crate::merged_costs::render_org(&report));
161 }
162 } else {
163 let outcomes = kranz_engine::outcomes::compute_outcomes(&repo)?;
164 if json {
165 println!("{}", output::render_outcomes_json(&outcomes)?);
166 } else {
167 print!("{}", output::render_outcomes(&outcomes));
168 }
169 }
170 Ok(0)
171 }
172 Command::EscalationMetrics { json } => {
173 let metrics = kranz_engine::escalation_metrics::compute_escalation_metrics(&repo)?;
174 if json {
175 println!("{}", output::render_escalation_metrics_json(&metrics)?);
176 } else {
177 print!("{}", output::render_escalation_metrics(&metrics));
178 }
179 Ok(0)
180 }
181 Command::Provenance { mission_id, json } => {
182 let mission = select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
183 let chain = kranz_engine::provenance::compute_provenance(&repo, &mission)?;
184 if json {
185 println!("{}", output::render_provenance_json(&chain)?);
186 } else {
187 print!("{}", output::render_provenance(&chain));
188 }
189 Ok(0)
190 }
191 Command::GateScores { gate, json } => {
192 let series = kranz_engine::gate_scores::compute_gate_score_series(&repo, &gate)?;
193 if json {
194 println!("{}", output::render_gate_score_series_json(&series)?);
195 } else {
196 print!("{}", output::render_gate_score_series(&series));
197 }
198 Ok(0)
199 }
200 Command::EvidenceBundle { mission_id, out } => {
201 let mission = select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
202 let out = out.unwrap_or_else(|| PathBuf::from(format!("evidence-bundle-{mission}")));
203 let outcome =
204 kranz_engine::evidence_bundle::export_evidence_bundle(&repo, &mission, &out)?;
205 println!(
206 "evidence bundle for mission {mission} written to {} ({} files; {} resolved artefacts, {} unresolved)",
207 outcome.out_dir.display(),
208 outcome.files_written,
209 outcome.resolved_artefacts,
210 outcome.unresolved_artefacts,
211 );
212 Ok(0)
213 }
214 Command::ExportTraces {
215 mission_id,
216 all,
217 out,
218 } => {
219 let jsonl = if all {
220 cmd_export_traces_all(&repo)
221 } else {
222 let mission =
223 select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
224 cmd_export_traces(&repo, &mission)?
225 };
226 match out {
227 Some(path) => {
231 kranz_engine::trace_export::write_export_output(&path, jsonl.as_bytes())
232 .with_context(|| {
233 format!("writing export-traces output to {}", path.display())
234 })?
235 }
236 None => print!("{jsonl}"),
237 }
238 Ok(0)
239 }
240 Command::ExportCorpus {
241 mission_id,
242 all,
243 out,
244 } => {
245 let jsonl = if all {
246 cmd_export_corpus_all(&repo)
247 } else {
248 let mission =
249 select_mission(&repo, mission_id.as_deref().or(cli.mission.as_deref()))?;
250 cmd_export_corpus(&repo, &mission)?
251 };
252 match out {
253 Some(path) => {
255 kranz_engine::trace_export::write_export_output(&path, jsonl.as_bytes())
256 .with_context(|| {
257 format!("writing export-corpus output to {}", path.display())
258 })?
259 }
260 None => print!("{jsonl}"),
261 }
262 Ok(0)
263 }
264 Command::Pause => {
265 let mission = select_control_mission(&repo, cli.mission.as_deref())?;
266 cmd_pause(&repo, &mission)?;
267 println!("pause queued for mission {mission} (takes effect between worker runs)");
268 if let Some(hint) = control_queue_hint(&repo, &mission) {
269 println!("{hint}");
270 }
271 Ok(0)
272 }
273 Command::Resume => {
274 let mission = select_control_mission(&repo, cli.mission.as_deref())?;
275 cmd_resume(&repo, &mission)?;
276 println!("resume queued for mission {mission} (takes effect between worker runs)");
277 if let Some(hint) = control_queue_hint(&repo, &mission) {
278 println!("{hint}");
279 }
280 Ok(0)
281 }
282 Command::Msg { text, interrupt } => {
283 let mission = select_control_mission(&repo, cli.mission.as_deref())?;
284 cmd_msg(&repo, &mission, &text, interrupt)?;
285 if interrupt {
286 println!(
287 "message queued for mission {mission} with --interrupt: the current worker \
288 run will be aborted (recorded as partial) before the message is injected"
289 );
290 } else {
291 println!(
292 "message queued for mission {mission}; it is processed between worker runs"
293 );
294 }
295 if let Some(hint) = control_queue_hint(&repo, &mission) {
296 println!("{hint}");
297 }
298 Ok(0)
299 }
300 Command::Revise { id, instructions } => {
301 let instructions = instructions.join(" ");
302 cmd_request_revision(&repo, &id, &instructions)?;
303 println!("revision request queued for mission {id}");
304 if let Some(hint) = control_queue_hint(&repo, &id) {
305 println!("{hint}");
306 }
307 Ok(0)
308 }
309 Command::Revision { command } => {
310 match command {
311 RevisionCommand::Approve { id, revision } => {
312 cmd_approve_revision(&repo, &id, revision)?;
313 println!("revision {revision} approval queued for mission {id}");
314 if let Some(hint) = control_queue_hint(&repo, &id) {
315 println!("{hint}");
316 }
317 }
318 RevisionCommand::Reject { id, revision } => {
319 cmd_reject_revision(&repo, &id, revision)?;
320 println!("revision {revision} rejection queued for mission {id}");
321 if let Some(hint) = control_queue_hint(&repo, &id) {
322 println!("{hint}");
323 }
324 }
325 }
326 Ok(0)
327 }
328 Command::Grant { command } => {
329 match command {
330 GrantCommand::Approve { id, command } => {
331 cmd_approve_grant(&repo, &id, &command)?;
332 println!("grant approval for `{command}` queued for mission {id}");
333 if let Some(hint) = control_queue_hint(&repo, &id) {
334 println!("{hint}");
335 }
336 }
337 GrantCommand::Deny {
338 id,
339 command,
340 reason,
341 } => {
342 cmd_deny_grant(&repo, &id, &command, &reason)?;
343 println!("grant denial for `{command}` queued for mission {id}");
344 if let Some(hint) = control_queue_hint(&repo, &id) {
345 println!("{hint}");
346 }
347 }
348 }
349 Ok(0)
350 }
351 Command::Question { command } => {
352 match command {
353 QuestionCommand::List { id } => {
354 print!("{}", cmd_list_questions(&repo, &id)?);
355 }
356 QuestionCommand::Answer {
357 id,
358 question_id,
359 answer,
360 option,
361 } => {
362 cmd_answer_question(&repo, &id, &question_id, &answer, option)?;
363 println!("answer for question {question_id} queued for mission {id}");
364 if let Some(hint) = control_queue_hint(&repo, &id) {
365 println!("{hint}");
366 }
367 }
368 }
369 Ok(0)
370 }
371 Command::Missions => {
372 print!("{}", cmd_missions(&repo)?);
373 Ok(0)
374 }
375 Command::Abandon { id, reason } => {
376 let mission = select_mission(&repo, id.as_deref().or(cli.mission.as_deref()))?;
379 let reason = reason.as_deref().unwrap_or("abandoned by operator");
380 cmd_abandon(&repo, &mission, reason, lock_force)?;
381 println!("mission {mission} ABANDONED ({reason})");
382 Ok(0)
383 }
384 Command::Clean { yes, all } => cmd_clean(&repo, yes, all),
385 Command::Ticket { command } => dispatch_ticket(&repo, command, cli.mission.as_deref()),
386 Command::Draft {
387 slug,
388 yes,
389 from_mission,
390 } => backlog::cmd_draft(
391 repo,
392 &slug,
393 yes,
394 from_mission.as_deref(),
395 cli.dangerously_allow_all,
396 )
397 .await
398 .map_err(augment_limit_hint),
399 Command::Decompose { goal, yes } => {
400 backlog::cmd_decompose(repo, &goal, yes, cli.dangerously_allow_all)
401 .await
402 .map_err(augment_limit_hint)
403 }
404 Command::Exec {
405 file,
406 yes: _,
407 max_cycles,
408 enqueue,
409 enqueue_source,
410 enqueue_external_ref,
411 push,
412 allow_unvalidated,
413 } => crate::exec::cmd_exec(
414 repo,
415 file,
416 crate::exec::ExecOptions {
417 max_cycles,
418 enqueue,
419 enqueue_source: enqueue_source.zip(enqueue_external_ref).map(
420 |(producer, external_ref)| crate::exec::ExternalEnqueueSource {
421 producer,
422 external_ref,
423 },
424 ),
425 push,
426 dangerously_allow_all: cli.dangerously_allow_all,
427 allow_unvalidated,
428 },
429 )
430 .await
431 .map_err(augment_limit_hint),
432 Command::Queue { remove } => match remove {
433 Some(mission_id) => {
434 print!("{}", backlog::cmd_queue_remove(&repo, &mission_id)?);
435 Ok(0)
436 }
437 None => {
438 print!("{}", backlog::cmd_queue(&repo));
439 Ok(0)
440 }
441 },
442 Command::KnowledgeRefresh { json } => cmd_knowledge_refresh(&repo, json),
443 Command::Scan { staged, range } => cmd_scan(&repo, staged, range.as_deref()),
444 Command::DomainLint { seed_config, json } => {
445 cmd_domain_lint(&repo, seed_config.as_deref(), json)
446 }
447 Command::HookGuard { config } => {
448 let mut stdin = std::io::stdin();
449 Ok(crate::hook_guard::run_hook_guard(&config, &mut stdin))
450 }
451 Command::HookStatus { config } => {
452 let mut stdin = std::io::stdin();
453 Ok(crate::hook_status::run_hook_status(
454 &config,
455 &mut stdin,
456 &crate::hook_status::post_signal,
457 )
458 .await)
459 }
460 Command::Ready { json, all } => {
461 if all {
462 let config = kranz_engine::paths::global_config()
463 .ok_or_else(|| anyhow::anyhow!("cannot locate the home directory"))?;
464 let report = crate::ready::assess_all(&config);
465 if json {
466 println!("{}", serde_json::to_string_pretty(&report)?);
467 } else {
468 print!("{}", crate::ready::render_org(&report));
469 }
470 } else {
471 let report = crate::ready::assess(&repo);
472 if json {
473 println!("{}", serde_json::to_string_pretty(&report)?);
474 } else {
475 print!("{}", crate::ready::render(&report));
476 }
477 }
478 Ok(0)
479 }
480 Command::Work { once, expect } => backlog::cmd_work(repo, once, expect)
481 .await
482 .map_err(augment_limit_hint),
483 Command::Serve {
484 port,
485 host,
486 insecure_lan,
487 read_auth,
488 open,
489 dashboard,
490 token,
491 read_token,
492 slack,
493 } => {
494 cmd_serve(
495 repo,
496 host,
497 port,
498 insecure_lan,
499 read_auth,
500 open,
501 dashboard,
502 token,
503 read_token,
504 slack,
505 )
506 .await
507 }
508 Command::Release { url, token } => {
509 let mission = select_mission(&repo, cli.mission.as_deref())?;
510 cmd_release(&repo, &mission, &url, token).await
511 }
512 Command::Config { command } => {
513 crate::config_cmd::cmd_config(&repo, command, cli.mission.as_deref())
514 }
515 Command::Pack { command } => match command {
516 crate::cli::PackCommand::Lint { dir } => cmd_pack_lint(&repo, &dir),
517 },
518 Command::Standards { command } => match command {
519 crate::cli::StandardsCommand::Metrics { json } => {
520 let report = kranz_engine::standards_metrics::compute(&repo)?;
521 if json {
522 println!("{}", serde_json::to_string_pretty(&report)?);
523 } else {
524 print!("{}", crate::output::render_standards_metrics(&report));
525 }
526 Ok(0)
527 }
528 crate::cli::StandardsCommand::Lint { dir, against } => {
529 cmd_standards_lint(&repo, &dir, against.as_deref())
530 }
531 crate::cli::StandardsCommand::Waive {
532 rule,
533 revision,
534 finding,
535 reason,
536 expires,
537 } => {
538 let mission = select_mission(&repo, cli.mission.as_deref())?;
539 cmd_standards_waive(
540 &repo,
541 &mission,
542 &rule,
543 revision,
544 finding.as_deref(),
545 &reason,
546 &expires,
547 lock_force,
548 )
549 }
550 crate::cli::StandardsCommand::Attest { rule, reason } => {
551 let mission = select_mission(&repo, cli.mission.as_deref())?;
552 cmd_standards_attest(&repo, &mission, &rule, &reason, lock_force)
553 }
554 },
555 Command::Otel {
556 endpoint,
557 from_start,
558 } => crate::otel::run_otel(repo, cli.mission.clone(), endpoint, from_start).await,
559 }
560}
561
562fn dispatch_ticket(repo: &Path, command: TicketCommand, mission: Option<&str>) -> Result<i32> {
566 match command {
567 TicketCommand::List => {
568 print!("{}", backlog::cmd_ticket_list(repo));
569 Ok(0)
570 }
571 TicketCommand::Ready { include_deferred } => {
572 print!("{}", backlog::cmd_ticket_ready(repo, include_deferred));
573 Ok(0)
574 }
575 TicketCommand::Show { slug } => {
576 print!("{}", backlog::cmd_ticket_show(repo, &slug)?);
577 Ok(0)
578 }
579 TicketCommand::New { slug, title, goal } => {
580 let path = backlog::cmd_ticket_new(repo, &slug, &title, goal.as_deref())?;
581 println!("created ticket '{slug}' at {}", path.display());
582 Ok(0)
583 }
584 TicketCommand::ImportOpenspec { path, slug } => {
585 let written = crate::openspec::import_change(repo, &path, slug.as_deref())?;
586 println!("imported {} as {}", path.display(), written.display());
587 println!(
588 "acceptance criteria are still prose: replace the placeholder in \
589 '## Acceptance hints' with commands that can fail"
590 );
591 Ok(0)
592 }
593 TicketCommand::Note { slug, text } => {
594 print!(
595 "{}",
596 crate::ticket_notes::cmd_ticket_note(repo, &slug, &text.join(" "))?
597 );
598 Ok(0)
599 }
600 TicketCommand::Notes { slug } => {
601 print!("{}", crate::ticket_notes::cmd_ticket_notes(repo, &slug)?);
602 Ok(0)
603 }
604 TicketCommand::Queue {
605 slug,
606 mission: explicit,
607 force,
608 } => {
609 backlog::cmd_ticket_queue(repo, &slug, explicit.as_deref().or(mission), force)
611 }
612 TicketCommand::Approve {
613 slug,
614 mission: explicit,
615 force,
616 } => {
617 backlog::cmd_ticket_approve(repo, &slug, explicit.as_deref().or(mission), force)
620 }
621 TicketCommand::MigrateState { yes } => backlog::cmd_ticket_migrate_state(repo, yes),
622 }
623}
624
625pub fn load_config(repo: &Path, dangerously_allow_all: bool) -> Result<MissionConfig> {
632 let mut cfg = config::load(repo)?;
633 if dangerously_allow_all {
634 cfg.dangerously_allow_all = true;
635 }
636 config::validate(&cfg)?;
637 Ok(cfg)
638}
639
640pub(crate) fn build_backend(cfg: &MissionConfig) -> Result<Arc<dyn AgentBackend>> {
643 let backend = ClaudeBackend::discover(cfg.claude_binary.as_deref())?;
644 Ok(Arc::new(backend))
645}
646
647pub fn select_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
650 if let Some(id) = explicit {
651 require_mission(repo, id)?;
652 return Ok(id.to_string());
653 }
654 let ids = MissionPaths::list_missions(repo);
655 match ids.len() {
656 0 => bail!(
657 "no missions found under {}; create one with `kranz plan \"<goal>\"`",
658 repo.join(".kranz").join("missions").display()
659 ),
660 1 => Ok(ids.into_iter().next().expect("len checked")),
661 _ => {
662 let mut best: Option<(SystemTime, String)> = None;
663 for id in ids {
664 let events = MissionPaths::new(repo, &id).events_file();
665 let mtime = std::fs::metadata(&events)
666 .and_then(|m| m.modified())
667 .unwrap_or(SystemTime::UNIX_EPOCH);
668 let newer = match &best {
669 Some((t, _)) => mtime >= *t,
670 None => true,
671 };
672 if newer {
673 best = Some((mtime, id));
674 }
675 }
676 Ok(best.expect("non-empty list").1)
677 }
678 }
679}
680
681pub fn select_control_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
693 if explicit.is_some() {
694 return Ok(control::resolve_active_mission(repo, explicit)?);
695 }
696 let mission = select_mission(repo, None)?;
697 let status = load_state(repo, &mission)?.mission.status;
698 if mission_catalog::is_terminal_status(status) {
699 bail!(
700 "mission {mission} is {status:?}; control commands apply only to active \
701 missions (a terminal mission's inbox is never drained — see \
702 `kranz missions`)"
703 );
704 }
705 Ok(mission)
706}
707
708pub fn control_queue_hint(repo: &Path, mission_id: &str) -> Option<String> {
713 let paths = MissionPaths::new(repo, mission_id);
714 (!mission_catalog::mission_lock_is_live(&paths)).then(|| {
715 format!(
716 "note: mission {mission_id} is not currently running — the command is \
717 queued and applies when the mission next runs"
718 )
719 })
720}
721
722pub fn select_planning_mission(repo: &Path, explicit: Option<&str>) -> Result<String> {
726 if let Some(id) = explicit {
727 require_mission(repo, id)?;
728 return Ok(id.to_string());
729 }
730 let mut best: Option<(SystemTime, String)> = None;
731 for id in MissionPaths::list_missions(repo) {
732 let Ok(state) = load_state(repo, &id) else {
733 continue; };
735 if state.mission.status != MissionStatus::Planning {
736 continue;
737 }
738 let events = MissionPaths::new(repo, &id).events_file();
739 let mtime = std::fs::metadata(&events)
740 .and_then(|m| m.modified())
741 .unwrap_or(SystemTime::UNIX_EPOCH);
742 if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
743 best = Some((mtime, id));
744 }
745 }
746 best.map(|(_, id)| id).ok_or_else(|| {
747 anyhow!(
748 "no mission is currently in planning under {} — start one with \
749 `kranz plan \"<goal>\"`",
750 repo.join(".kranz").join("missions").display()
751 )
752 })
753}
754
755pub fn augment_limit_hint(e: anyhow::Error) -> anyhow::Error {
759 let msg = format!("{e:#}").to_ascii_lowercase();
760 if [
761 "session limit",
762 "usage limit",
763 "rate limit",
764 "hit your limit",
765 ]
766 .iter()
767 .any(|s| msg.contains(s))
768 {
769 e.context(
770 "this is your Claude subscription's usage window, not a Kranz failure. \
771 The mission and its conversation are saved: when the limit resets, \
772 `kranz plan` (no goal) resumes planning and `kranz run` resumes execution",
773 )
774 } else {
775 e
776 }
777}
778
779fn require_mission(repo: &Path, mission_id: &str) -> Result<MissionPaths> {
781 if !MissionPaths::is_safe_id(mission_id) {
782 bail!(
783 "invalid mission id '{mission_id}': ids cannot contain path separators, '..', or drive designators"
784 );
785 }
786 let paths = MissionPaths::new(repo, mission_id);
787 if !paths.events_file().is_file() {
788 bail!(
789 "mission '{mission_id}' not found under {} (see `kranz missions`)",
790 paths.missions_dir().display()
791 );
792 }
793 Ok(paths)
794}
795
796pub fn load_state(repo: &Path, mission_id: &str) -> Result<MissionState> {
799 let paths = require_mission(repo, mission_id)?;
800 let events = EventLog::read_events(&paths.events_file())
801 .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
802 let state = reducer::fold(&events)
803 .with_context(|| format!("folding the event log of mission '{mission_id}'"))?;
804 Ok(state)
805}
806
807pub fn cmd_export_traces(repo: &Path, mission_id: &str) -> Result<String> {
812 let paths = require_mission(repo, mission_id)?;
813 let events = EventLog::read_events(&paths.events_file())
814 .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
815 let state = reducer::fold(&events)
816 .with_context(|| format!("folding the event log of mission '{mission_id}'"))?;
817 let pairs = trace_export::export_validated_traces(&state, &events);
818 Ok(trace_export::to_jsonl(&pairs))
819}
820
821pub fn cmd_export_traces_all(repo: &Path) -> String {
826 let mut pairs = Vec::new();
827 for mission_id in MissionPaths::list_missions(repo) {
828 let paths = MissionPaths::new(repo, &mission_id);
829 let Ok(events) = EventLog::read_events(&paths.events_file()) else {
830 continue;
831 };
832 let Ok(state) = reducer::fold(&events) else {
833 continue;
834 };
835 pairs.extend(trace_export::export_validated_traces(&state, &events));
836 }
837 trace_export::to_jsonl(&pairs)
838}
839
840pub fn cmd_export_corpus(repo: &Path, mission_id: &str) -> Result<String> {
847 let paths = require_mission(repo, mission_id)?;
848 paths.require_no_follow()?;
849 let events = EventLog::read_events(&paths.events_file())
850 .with_context(|| format!("reading the event log of mission '{mission_id}'"))?;
851 let records = corpus_export::export_corpus(&paths.mission_dir(), mission_id, &events)
852 .with_context(|| format!("deriving the training corpus of mission '{mission_id}'"))?;
853 Ok(corpus_export::to_jsonl(&records))
854}
855
856pub fn cmd_export_corpus_all(repo: &Path) -> String {
862 let mut records = Vec::new();
863 for mission_id in MissionPaths::list_missions(repo) {
864 let paths = MissionPaths::new(repo, &mission_id);
865 if paths.require_no_follow().is_err() {
866 continue;
867 }
868 let Ok(events) = EventLog::read_events(&paths.events_file()) else {
869 continue;
870 };
871 let Ok(mission_records) =
872 corpus_export::export_corpus(&paths.mission_dir(), &mission_id, &events)
873 else {
874 continue;
875 };
876 records.extend(mission_records);
877 }
878 corpus_export::to_jsonl(&records)
879}
880
881pub fn print_danger_banner() {
883 eprintln!(
884 "\n\
885 ============================================================\n\
886 !! --dangerously-allow-all IS SET !!\n\
887 !! !!\n\
888 !! Permission gating is BYPASSED for every agent !!\n\
889 !! session (bypassPermissions). Workers can run ANY !!\n\
890 !! command: file writes, network access, git push, !!\n\
891 !! package publishes, sudo. !!\n\
892 !! !!\n\
893 !! Only use this on a sandboxed, disposable checkout. !!\n\
894 ============================================================\n"
895 );
896}
897
898struct StdinLines {
910 rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<String>>>,
911}
912
913impl StdinLines {
914 fn spawn() -> Self {
915 static CHANNEL: std::sync::OnceLock<
916 Arc<tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<String>>>,
917 > = std::sync::OnceLock::new();
918 let rx = CHANNEL
919 .get_or_init(|| {
920 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
921 std::thread::spawn(move || {
922 let mut buf = String::new();
923 loop {
924 buf.clear();
925 match std::io::stdin().read_line(&mut buf) {
926 Ok(0) | Err(_) => break, Ok(_) => {
928 let line = buf.trim_end_matches(['\r', '\n']).to_string();
929 if tx.send(line).is_err() {
930 break;
931 }
932 }
933 }
934 }
935 });
936 Arc::new(tokio::sync::Mutex::new(rx))
937 })
938 .clone();
939 StdinLines { rx }
940 }
941
942 async fn next(&mut self) -> Option<String> {
944 self.rx.lock().await.recv().await
945 }
946
947 fn drain(&mut self) -> usize {
949 let Ok(mut rx) = self.rx.try_lock() else {
952 return 0;
953 };
954 let mut n = 0;
955 while rx.try_recv().is_ok() {
956 n += 1;
957 }
958 n
959 }
960
961 fn drain_noisily(&mut self, tty: bool) {
965 if !std::io::stdin().is_terminal() {
966 return;
967 }
968 let n = self.drain();
969 if n > 0 {
970 let (dim, reset) = if tty {
971 (ansi::DIM, ansi::RESET)
972 } else {
973 ("", "")
974 };
975 eprintln!(
976 "{dim}(ignored {n} line(s) typed while the orchestrator was working — \
977 the prompt below wants fresh input){reset}"
978 );
979 }
980 }
981}
982
983async fn cmd_plan(
991 repo: PathBuf,
992 goal: Option<String>,
993 cfg: MissionConfig,
994 explicit_mission: Option<&str>,
995 force_lock: LockForce,
996) -> Result<i32> {
997 let backend = build_backend(&cfg)?;
998 let (mut engine, intro) = match goal {
999 Some(goal) => {
1000 let engine = MissionEngine::create(backend, repo.clone(), &goal, cfg)?;
1001 let intro = format!("mission {} created (planning)", engine.mission_id());
1002 (engine, intro)
1003 }
1004 None => {
1005 let mission = select_planning_mission(&repo, explicit_mission)?;
1006 let engine = MissionEngine::resume(backend, repo.clone(), &mission, force_lock)?;
1007 if engine.state().mission.status != MissionStatus::Planning {
1008 return Err(anyhow!(
1009 "mission {mission} is {:?}, not in planning — use 'kranz run' \
1010 to execute it, or 'kranz plan \"<goal>\"' to start a new mission",
1011 engine.state().mission.status
1012 ));
1013 }
1014 let intro = format!(
1015 "resuming planning for mission {mission} — the conversation continues \
1016 where it left off"
1017 );
1018 (engine, intro)
1019 }
1020 };
1021
1022 if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
1028 let mission_id = engine.mission_id().to_string();
1029 return match crate::planning_tui::run(engine, intro).await? {
1033 PlanningOutcome::ApprovedRun => start_run_after_plan(repo, mission_id).await,
1034 PlanningOutcome::ApprovedExit | PlanningOutcome::NotApproved => Ok(0),
1035 };
1036 }
1037 println!("{intro}");
1038 let tty = std::io::stdout().is_terminal();
1039
1040 let color = std::io::stderr().is_terminal();
1043 let stop = Arc::new(AtomicBool::new(false));
1044 let printer = tokio::spawn(tail::tail_events(
1045 engine.paths().events_file(),
1046 engine.state().last_seq,
1047 EventRenderer::planning(engine.state(), color),
1048 Arc::clone(&stop),
1049 ));
1050 let mut approved = false;
1051 let mut run_now = false;
1052 let mut stdin_lines = StdinLines::spawn();
1053
1054 println!("talk to the orchestrator to shape the plan:");
1055 println!(" /plan request the plan + cost estimate and review it for approval");
1056 println!(" /quit exit planning (Ctrl-D works too)");
1057
1058 loop {
1059 stdin_lines.drain_noisily(tty);
1060 if tty {
1061 print!("you> ");
1062 let _ = std::io::stdout().flush();
1063 }
1064 let Some(line) = stdin_lines.next().await else {
1065 break; };
1067 let line = line.trim().to_string();
1068 if line.is_empty() {
1069 continue;
1070 }
1071 match line.as_str() {
1072 "/quit" => break,
1073 "/plan" => {
1074 let request = engine.request_plan().await;
1075 if let Some(seed) = engine.take_seed_reply() {
1078 print_orchestrator_reply(&seed, tty);
1079 }
1080 let plan = match request {
1081 Ok(PlanRequest::Ready(plan)) => plan,
1082 Ok(PlanRequest::NotReady(text)) => {
1083 print_orchestrator_reply(&text, tty);
1086 println!(
1087 "the orchestrator isn't ready to emit the plan yet — answer it \
1088 above, then /plan again."
1089 );
1090 continue;
1091 }
1092 Ok(PlanRequest::WrongPlan { reason }) => {
1093 print_orchestrator_reply(&reason, tty);
1096 println!(
1097 "the orchestrator believes a plan here is likely WRONG — reframe \
1098 the goal or fix the premise above, then /plan again."
1099 );
1100 continue;
1101 }
1102 Err(e) => {
1103 eprintln!(
1104 "kranz: plan request failed: {:#}",
1105 augment_limit_hint(e.into())
1106 );
1107 continue;
1108 }
1109 };
1110 println!("{}", output::render_plan(&plan));
1111 let calibration = cost::calibrate(&repo);
1114 let estimate = cost::estimate(&plan, &engine.state().config, &calibration.params);
1115 let estimate = cost::apply_shape(estimate, &plan, &calibration);
1116 println!(
1117 "{}",
1118 output::render_cost_estimate(&estimate, calibration.missions_used)
1119 );
1120
1121 stdin_lines.drain_noisily(tty);
1122 print!("approve? [y/N] ");
1123 let _ = std::io::stdout().flush();
1124 let answer = stdin_lines.next().await.unwrap_or_default();
1125 if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
1126 match engine.approve_plan(plan) {
1127 Ok(()) => {
1128 let branch = engine.state().mission.mission_branch.clone();
1129 approved = true;
1130 if std::io::stdin().is_terminal() {
1135 println!("plan approved and committed on {branch}.");
1136 stdin_lines.drain_noisily(tty);
1137 print!("start execution now? [Y/n] ");
1138 let _ = std::io::stdout().flush();
1139 let reply = stdin_lines.next().await;
1140 if run_now_answer(reply.as_deref()) {
1141 run_now = true;
1142 } else {
1143 println!("run 'kranz run' to execute.");
1144 }
1145 } else {
1146 println!(
1147 "plan approved and committed on {branch}. \
1148 run 'kranz run' to execute."
1149 );
1150 }
1151 break;
1152 }
1153 Err(e) => eprintln!("kranz: plan approval failed: {e}"),
1154 }
1155 } else {
1156 println!("not approved — back to the conversation.");
1157 }
1158 }
1159 _ if line.starts_with('/') => {
1160 println!("unknown command {line}; use /plan or /quit");
1161 }
1162 _ => {
1163 let result = engine.planning_turn(&line).await;
1164 if let Some(seed) = engine.take_seed_reply() {
1167 print_orchestrator_reply(&seed, tty);
1168 }
1169 match result {
1170 Ok(reply) => print_orchestrator_reply(&reply, tty),
1171 Err(e) => eprintln!(
1172 "kranz: orchestrator turn failed: {:#}",
1173 augment_limit_hint(e.into())
1174 ),
1175 }
1176 }
1177 }
1178 }
1179 if !approved {
1180 println!(
1181 "leaving planning; mission {} was not approved. Resume anytime with `kranz plan`.",
1182 engine.mission_id()
1183 );
1184 }
1185 let mission_id = engine.mission_id().to_string();
1186 drop(engine);
1189 stop.store(true, Ordering::Relaxed);
1190 let _ = printer.await;
1191 if run_now {
1192 return start_run_after_plan(repo, mission_id).await;
1195 }
1196 Ok(0)
1197}
1198
1199pub fn run_now_answer(answer: Option<&str>) -> bool {
1204 match answer {
1205 None => false,
1206 Some(text) => !matches!(text.trim().to_ascii_lowercase().as_str(), "n" | "no"),
1207 }
1208}
1209
1210async fn start_run_after_plan(repo: PathBuf, mission_id: String) -> Result<i32> {
1214 println!(
1215 "starting mission {mission_id} — live event feed follows \
1216 (Ctrl-C safe; resume with 'kranz run')"
1217 );
1218 run_mission_loop(repo, mission_id, LockForce::No, true).await
1219}
1220
1221fn print_orchestrator_reply(text: &str, tty: bool) {
1223 let prefix = if tty {
1224 format!("{}orchestrator>{} ", ansi::DIM, ansi::RESET)
1225 } else {
1226 "orchestrator> ".to_string()
1227 };
1228 for line in text.lines() {
1229 println!("{prefix}{line}");
1230 }
1231}
1232
1233async fn cmd_run(
1240 repo: PathBuf,
1241 mission: String,
1242 force_lock: LockForce,
1243 dangerously_allow_all: bool,
1244) -> Result<i32> {
1245 if dangerously_allow_all {
1248 let paths = require_mission(&repo, &mission)?;
1249 control::enqueue(
1250 &paths,
1251 &ControlCommand::ConfigChange {
1252 patch: serde_json::json!({ "dangerouslyAllowAll": true }),
1253 },
1254 )?;
1255 }
1256 run_mission_loop(repo, mission, force_lock, true).await
1257}
1258
1259pub(crate) async fn run_mission_loop(
1263 repo: PathBuf,
1264 mission: String,
1265 force_lock: LockForce,
1266 interactive: bool,
1270) -> Result<i32> {
1271 let cfg = load_config(&repo, false)?;
1272 let backend = build_backend(&cfg)?;
1273 run_mission_loop_with_backend(repo, mission, force_lock, interactive, backend).await
1274}
1275
1276async fn run_mission_loop_with_backend(
1280 repo: PathBuf,
1281 mission: String,
1282 force_lock: LockForce,
1283 interactive: bool,
1284 backend: Arc<dyn AgentBackend>,
1285) -> Result<i32> {
1286 let paths = require_mission(&repo, &mission)?;
1287
1288 loop {
1289 let mut engine =
1290 MissionEngine::resume(Arc::clone(&backend), repo.clone(), &mission, force_lock)?;
1291
1292 let color = std::io::stderr().is_terminal();
1294 let renderer = EventRenderer::seeded(engine.state(), color);
1295 let stop = Arc::new(AtomicBool::new(false));
1296 let printer = tokio::spawn(tail::tail_events(
1297 engine.paths().events_file(),
1298 engine.state().last_seq,
1299 renderer,
1300 Arc::clone(&stop),
1301 ));
1302
1303 let run_result = engine.run().await;
1304 drop(engine);
1307 stop.store(true, Ordering::Relaxed);
1308 let _ = printer.await;
1309
1310 let status = run_result?;
1311 if let Err(e) = kranz_engine::work::reconcile_ticket_for_mission(&repo, &mission) {
1315 eprintln!("kranz run: warning: failed to reconcile linked ticket: {e}");
1316 }
1317
1318 match status {
1319 MissionStatus::Complete => {
1320 println!("mission {mission} COMPLETE");
1321 return Ok(0);
1322 }
1323 MissionStatus::Blocked => {
1324 eprintln!(
1325 "\n\
1326 ==================== MILESTONE BLOCKED ====================\n\
1327 A milestone is blocked (fix-cycle cap reached or blocked by\n\
1328 the orchestrator). Inspect it with `kranz status`.\n\
1329 ==========================================================="
1330 );
1331 if interactive && std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
1335 {
1336 print!(
1337 "guidance for the orchestrator (what to do about the block; \
1338 empty line or Ctrl-D exits)\nguidance> "
1339 );
1340 let _ = std::io::stdout().flush();
1341 let mut lines = StdinLines::spawn();
1342 if let Some(text) = lines.next().await {
1343 let text = text.trim().to_string();
1344 if !text.is_empty() {
1345 control::enqueue(
1346 &paths,
1347 &ControlCommand::Msg {
1348 text,
1349 interrupt: false,
1350 },
1351 )?;
1352 println!("guidance queued — resuming the mission…");
1353 continue;
1354 }
1355 }
1356 }
1357 eprintln!(
1358 "unblock later by sending guidance via `kranz msg \"<text>\"` \
1359 and re-running `kranz run`."
1360 );
1361 println!("mission {mission} BLOCKED");
1362 return Ok(2);
1363 }
1364 MissionStatus::Failed => {
1365 println!("mission {mission} FAILED");
1366 return Ok(1);
1367 }
1368 other => {
1369 println!(
1370 "mission {mission} ended as {}",
1371 output::mission_status_label(other)
1372 );
1373 return Ok(1);
1374 }
1375 }
1376 }
1377}
1378
1379pub fn cmd_pause(repo: &Path, mission_id: &str) -> Result<PathBuf> {
1385 let paths = require_mission(repo, mission_id)?;
1386 Ok(control::enqueue(&paths, &ControlCommand::Pause)?)
1387}
1388
1389pub fn cmd_resume(repo: &Path, mission_id: &str) -> Result<PathBuf> {
1391 let paths = require_mission(repo, mission_id)?;
1392 Ok(control::enqueue(&paths, &ControlCommand::Resume)?)
1393}
1394
1395pub fn cmd_msg(repo: &Path, mission_id: &str, text: &str, interrupt: bool) -> Result<PathBuf> {
1397 let paths = require_mission(repo, mission_id)?;
1398 let cmd = ControlCommand::Msg {
1399 text: text.to_string(),
1400 interrupt,
1401 };
1402 Ok(control::enqueue(&paths, &cmd)?)
1403}
1404
1405pub fn cmd_request_revision(repo: &Path, mission_id: &str, instructions: &str) -> Result<PathBuf> {
1407 let instructions = instructions.trim();
1408 if instructions.is_empty() {
1409 bail!("revision instructions must not be empty");
1410 }
1411 let paths = require_revisable_mission(repo, mission_id)?;
1412 Ok(control::enqueue(
1413 &paths,
1414 &ControlCommand::RequestRevision {
1415 instructions: instructions.to_string(),
1416 },
1417 )?)
1418}
1419
1420pub fn cmd_approve_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<PathBuf> {
1422 let paths = require_pending_revision(repo, mission_id, revision)?;
1423 Ok(control::enqueue(
1424 &paths,
1425 &ControlCommand::ApproveRevision { revision },
1426 )?)
1427}
1428
1429pub fn cmd_reject_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<PathBuf> {
1431 let paths = require_pending_revision(repo, mission_id, revision)?;
1432 Ok(control::enqueue(
1433 &paths,
1434 &ControlCommand::RejectRevision { revision },
1435 )?)
1436}
1437
1438pub fn cmd_approve_grant(repo: &Path, mission_id: &str, command: &str) -> Result<PathBuf> {
1440 let paths = require_pending_grant(repo, mission_id, command)?;
1441 Ok(control::enqueue(
1442 &paths,
1443 &ControlCommand::ApproveGrant {
1444 command: command.to_string(),
1445 },
1446 )?)
1447}
1448
1449pub fn cmd_deny_grant(
1451 repo: &Path,
1452 mission_id: &str,
1453 command: &str,
1454 reason: &str,
1455) -> Result<PathBuf> {
1456 let paths = require_pending_grant(repo, mission_id, command)?;
1457 Ok(control::enqueue(
1458 &paths,
1459 &ControlCommand::DenyGrant {
1460 command: command.to_string(),
1461 reason: reason.to_string(),
1462 },
1463 )?)
1464}
1465
1466pub fn cmd_list_questions(repo: &Path, mission_id: &str) -> Result<String> {
1471 let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1472 let state = load_state(repo, &mission_id)?;
1473 if state.pending_questions.is_empty() {
1474 return Ok(format!("mission {mission_id} has no open questions\n"));
1475 }
1476 let mut out = String::new();
1477 for q in &state.pending_questions {
1478 out.push_str(&format!(
1479 "{} ({}): {}\n",
1480 q.question_id,
1481 q.feature_id.as_deref().unwrap_or("mission"),
1482 q.text
1483 ));
1484 if q.options.is_empty() {
1485 out.push_str(" free-text answer expected\n");
1486 } else {
1487 for (index, option) in q.options.iter().enumerate() {
1488 out.push_str(&format!(" [{index}] {option}\n"));
1489 }
1490 }
1491 }
1492 Ok(out)
1493}
1494
1495pub fn cmd_answer_question(
1498 repo: &Path,
1499 mission_id: &str,
1500 question_id: &str,
1501 answer: &str,
1502 option: Option<u32>,
1503) -> Result<PathBuf> {
1504 let paths = require_pending_question(repo, mission_id, question_id, option, answer)?;
1505 Ok(control::enqueue(
1506 &paths,
1507 &ControlCommand::AnswerQuestion {
1508 question_id: question_id.to_string(),
1509 answer: answer.to_string(),
1510 option,
1511 },
1512 )?)
1513}
1514
1515fn require_pending_question(
1521 repo: &Path,
1522 mission_id: &str,
1523 question_id: &str,
1524 option: Option<u32>,
1525 answer: &str,
1526) -> Result<MissionPaths> {
1527 let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1528 let state = load_state(repo, &mission_id)?;
1529 let Some(pending) = state
1530 .pending_questions
1531 .iter()
1532 .find(|q| q.question_id == question_id)
1533 else {
1534 bail!("mission {mission_id} has no open question '{question_id}'");
1535 };
1536 if let Some(index) = option {
1537 match pending.options.get(index as usize) {
1538 Some(expected) if expected == answer => {}
1539 Some(expected) => bail!(
1540 "answer `{answer}` does not match option {index} (`{expected}`) of question '{question_id}'"
1541 ),
1542 None => bail!(
1543 "question '{question_id}' has no option {index} (it offered {})",
1544 pending.options.len()
1545 ),
1546 }
1547 }
1548 Ok(MissionPaths::new(repo, &mission_id))
1549}
1550
1551fn require_revisable_mission(repo: &Path, mission_id: &str) -> Result<MissionPaths> {
1552 let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1553 let state = load_state(repo, &mission_id)?;
1554 if state.mission.status == MissionStatus::Planning {
1555 bail!("mission {mission_id} has no approved plan to revise yet");
1556 }
1557 Ok(MissionPaths::new(repo, &mission_id))
1558}
1559
1560fn require_pending_revision(repo: &Path, mission_id: &str, revision: u32) -> Result<MissionPaths> {
1561 let paths = require_revisable_mission(repo, mission_id)?;
1562 let state = load_state(repo, mission_id)?;
1563 match state.pending_revision {
1564 Some(pending) if pending.revision == revision => Ok(paths),
1565 Some(pending) => bail!(
1566 "mission {mission_id} is awaiting revision {}, not {revision}",
1567 pending.revision
1568 ),
1569 None => bail!("mission {mission_id} has no pending revision"),
1570 }
1571}
1572
1573fn require_pending_grant(repo: &Path, mission_id: &str, command: &str) -> Result<MissionPaths> {
1577 let mission_id = control::resolve_active_mission(repo, Some(mission_id))?;
1578 let state = load_state(repo, &mission_id)?;
1579 match state.pending_grant_request {
1580 Some(pending) if pending.command == command => Ok(MissionPaths::new(repo, &mission_id)),
1581 Some(pending) => bail!(
1582 "mission {mission_id} is awaiting a grant for `{}`, not `{command}`",
1583 pending.command
1584 ),
1585 None => bail!("mission {mission_id} has no pending grant request"),
1586 }
1587}
1588
1589pub fn cmd_knowledge_refresh(repo: &Path, json: bool) -> Result<i32> {
1593 let report = kranz_engine::knowledge::refresh_knowledge(repo);
1594 if json {
1595 println!("{}", serde_json::to_string_pretty(&report)?);
1596 } else if report.findings.is_empty() {
1597 println!("knowledge refresh: no notes");
1598 } else {
1599 for finding in &report.findings {
1600 let labels: Vec<String> = finding
1601 .verdicts
1602 .iter()
1603 .map(|v| match v {
1604 kranz_engine::knowledge::RefreshVerdict::Ok => "ok".into(),
1605 kranz_engine::knowledge::RefreshVerdict::AlreadyStale => "already-stale".into(),
1606 kranz_engine::knowledge::RefreshVerdict::Unverified => "unverified".into(),
1607 kranz_engine::knowledge::RefreshVerdict::InvalidMetadata { field, value } => {
1608 format!(
1609 "invalid-metadata:{field}:{}",
1610 value.as_deref().unwrap_or("missing")
1611 )
1612 }
1613 kranz_engine::knowledge::RefreshVerdict::InvalidCitation { citation } => {
1614 format!("invalid-citation:{citation}")
1615 }
1616 kranz_engine::knowledge::RefreshVerdict::PathMissing { path } => {
1617 format!("path-missing:{path}")
1618 }
1619 kranz_engine::knowledge::RefreshVerdict::PathDrifted { path } => {
1620 format!("path-drifted:{path}")
1621 }
1622 kranz_engine::knowledge::RefreshVerdict::CommandSkipped { command } => {
1623 format!("command-skipped:{command}")
1624 }
1625 kranz_engine::knowledge::RefreshVerdict::ProbeFailed { target, error } => {
1626 format!("probe-failed:{target}:{error}")
1627 }
1628 })
1629 .collect();
1630 println!(
1631 "{} ({}) [{}] {}",
1632 finding.rel_path,
1633 finding.title,
1634 finding.freshness,
1635 labels.join(", ")
1636 );
1637 }
1638 if report.check_needed() {
1639 println!("knowledge refresh: check-needed");
1640 } else {
1641 println!("knowledge refresh: ok");
1642 }
1643 }
1644 Ok(if report.check_needed() { 1 } else { 0 })
1645}
1646
1647pub fn cmd_scan(repo: &Path, staged: bool, range: Option<&str>) -> Result<i32> {
1648 if staged && range.is_some() {
1649 bail!("choose either --staged or --range, not both");
1650 }
1651 let git = kranz_engine::git_ops::GitRepo::open(repo)?;
1652 let diff = if staged {
1653 git.diff_staged()?
1654 } else if let Some(range) = range {
1655 git.diff_range(range)?
1656 } else {
1657 git.diff_range("HEAD")?
1658 };
1659 let allowed = std::fs::read_to_string(repo.join(kranz_engine::scrub::SECRET_ALLOWLIST_PATH))
1660 .ok()
1661 .map(|text| kranz_engine::scrub::read_allowlist_text(&text))
1662 .unwrap_or_default();
1663 let findings = kranz_engine::scrub::filter_allowed(
1664 kranz_engine::scrub::scan_unified_diff(&diff),
1665 &allowed,
1666 );
1667 if findings.is_empty() {
1668 println!("secret scan passed");
1669 Ok(0)
1670 } else {
1671 println!(
1672 "secret scan failed; add a fingerprint to {} only for a reviewed false positive:\n{}",
1673 kranz_engine::scrub::SECRET_ALLOWLIST_PATH,
1674 kranz_engine::scrub::format_findings(&findings)
1675 );
1676 Ok(2)
1677 }
1678}
1679
1680pub fn cmd_domain_lint(repo: &Path, seed_config: Option<&Path>, json: bool) -> Result<i32> {
1691 use kranz_engine::domain_lint as dl;
1692 let config_path = repo.join(dl::DENYLIST_PATH);
1693
1694 if let Some(terms_file) = seed_config {
1695 let terms = std::fs::read_to_string(terms_file)
1696 .with_context(|| format!("read terms file {}", terms_file.display()))?;
1697 let existing = std::fs::read_to_string(&config_path).ok();
1698 let config = dl::seed_config(existing.as_deref(), &terms)?;
1699 if let Some(parent) = config_path.parent() {
1701 std::fs::create_dir_all(parent)
1702 .with_context(|| format!("create {}", parent.display()))?;
1703 }
1704 std::fs::write(&config_path, &config)
1705 .with_context(|| format!("write {}", config_path.display()))?;
1706 let denylist = dl::load_denylist(&config)?;
1707 println!(
1709 "seeded {} ({} hashed terms, {})",
1710 dl::DENYLIST_PATH,
1711 denylist.term_count(),
1712 if existing.is_some() {
1713 "salt preserved"
1714 } else {
1715 "fresh salt"
1716 }
1717 );
1718 warn_if_terms_file_unprotected(repo, terms_file);
1719 return Ok(0);
1720 }
1721
1722 let config_text = std::fs::read_to_string(&config_path).with_context(|| {
1723 format!(
1724 "read {} — seed it with `kranz domain-lint --seed-config <terms-file>` (docs/domain-lint.md)",
1725 dl::DENYLIST_PATH
1726 )
1727 })?;
1728 let denylist = dl::load_denylist(&config_text)?;
1729 let allowed = std::fs::read_to_string(repo.join(dl::ALLOWLIST_PATH))
1730 .ok()
1731 .map(|text| kranz_engine::scrub::read_allowlist_text(&text))
1732 .unwrap_or_default();
1733 let report = dl::lint_tree(repo, &denylist, &allowed)?;
1734
1735 if json {
1736 println!(
1737 "{}",
1738 serde_json::to_string_pretty(&serde_json::json!({
1739 "passed": report.is_clean(),
1740 "filesScanned": report.files_scanned,
1741 "filesSkipped": report.files_skipped,
1742 "findings": report.findings,
1743 }))?
1744 );
1745 }
1746 if report.is_clean() {
1747 if !json {
1748 println!(
1749 "domain lint passed ({} files scanned)",
1750 report.files_scanned
1751 );
1752 }
1753 Ok(0)
1754 } else {
1755 if !json {
1756 println!(
1757 "domain lint failed: {} unwaived hit(s); add a fingerprint to {} only for a reviewed false positive:",
1758 report.findings.len(),
1759 dl::ALLOWLIST_PATH
1760 );
1761 for finding in &report.findings {
1762 println!("{} {}:{}", finding.fingerprint, finding.path, finding.line);
1763 }
1764 }
1765 Ok(1)
1766 }
1767}
1768
1769fn warn_if_terms_file_unprotected(repo: &Path, terms_file: &Path) {
1774 let (Ok(repo), Ok(terms_file)) = (repo.canonicalize(), terms_file.canonicalize()) else {
1775 return;
1776 };
1777 let Ok(relative) = terms_file.strip_prefix(&repo) else {
1778 return; };
1780 let ignored = std::process::Command::new("git")
1781 .args(["check-ignore", "-q", "--"])
1782 .arg(relative)
1783 .current_dir(&repo)
1784 .status()
1785 .map(|status| status.success())
1786 .unwrap_or(true);
1788 if !ignored {
1789 eprintln!(
1790 "warning: {} is inside the repo and NOT gitignored — move it outside the repo or use {} (gitignored)",
1791 relative.display(),
1792 kranz_engine::domain_lint::TERMS_LOCAL_PATH
1793 );
1794 }
1795}
1796
1797fn cmd_pack_lint(repo: &Path, dir: &Path) -> Result<i32> {
1808 let trust = kranz_engine::pack::standards::trust_for_dir(repo, dir);
1809 match kranz_engine::pack::Pack::load_with_trust(dir, trust) {
1810 Ok(Some(pack)) => {
1811 print!("{}", kranz_engine::pack::render_lint(&pack));
1812 Ok(0)
1813 }
1814 Ok(None) => {
1815 println!(
1816 "no pack at {} (no {}) — nothing to lint",
1817 dir.display(),
1818 kranz_engine::pack::PACK_MANIFEST
1819 );
1820 Ok(0)
1821 }
1822 Err(err) => {
1823 eprintln!("invalid pack at {}: {err}", dir.display());
1824 Ok(1)
1825 }
1826 }
1827}
1828
1829fn cmd_standards_lint(repo: &Path, dir: &Path, against: Option<&str>) -> Result<i32> {
1836 let trust = kranz_engine::pack::standards::trust_for_dir(repo, dir);
1837 let pack = match kranz_engine::pack::Pack::load_with_trust(dir, trust) {
1838 Ok(Some(pack)) => pack,
1839 Ok(None) => {
1840 println!(
1841 "no pack at {} (no {}) — nothing to lint",
1842 dir.display(),
1843 kranz_engine::pack::PACK_MANIFEST
1844 );
1845 return Ok(0);
1846 }
1847 Err(err) => {
1848 eprintln!("invalid pack at {}: {err}", dir.display());
1849 return Ok(1);
1850 }
1851 };
1852 let Some(manifest) = &pack.standards else {
1853 println!(
1854 "pack `{}` (schema {}) at {} declares no [standards] root — nothing to lint",
1855 pack.name,
1856 pack.schema,
1857 dir.display()
1858 );
1859 return Ok(0);
1860 };
1861 print!(
1862 "{}",
1863 kranz_engine::pack::standards::render_manifest(manifest, trust)
1864 );
1865 if let Some(refname) = against {
1866 let Some(pack_rel) = kranz_engine::pack::standards::repo_relative_dir(repo, dir) else {
1870 eprintln!(
1871 "--against reads the base pack from tracked git blobs in {}; {} is outside \
1872 the repo — external packs have no base history to compare against (D-A)",
1873 repo.display(),
1874 dir.display()
1875 );
1876 return Ok(1);
1877 };
1878 let git = kranz_engine::git_ops::GitRepo::open(repo)?;
1879 let base = match kranz_engine::pack::standards::load_at_ref(&git, refname, &pack_rel) {
1880 Ok(base) => base,
1881 Err(err) => {
1882 eprintln!("cannot load the base standards at `{refname}`: {err}");
1883 return Ok(1);
1884 }
1885 };
1886 let errors = kranz_engine::pack::standards::check_transitions(base.as_ref(), manifest);
1887 print!(
1888 "{}",
1889 kranz_engine::pack::standards::render_transition_report(
1890 refname,
1891 base.as_ref(),
1892 &errors
1893 )
1894 );
1895 if !errors.is_empty() {
1896 return Ok(1);
1897 }
1898 }
1899 Ok(0)
1900}
1901
1902#[allow(clippy::too_many_arguments)]
1912fn cmd_standards_waive(
1913 repo: &Path,
1914 mission_id: &str,
1915 rule: &str,
1916 revision: Option<u64>,
1917 finding: Option<&str>,
1918 reason: &str,
1919 expires: &str,
1920 force_lock: LockForce,
1921) -> Result<i32> {
1922 let expires_at = match chrono::DateTime::parse_from_rfc3339(expires) {
1923 Ok(parsed) => parsed.with_timezone(&chrono::Utc),
1924 Err(err) => {
1925 eprintln!("waiver refused: --expires must be an RFC 3339 instant: {err}");
1926 return Ok(1);
1927 }
1928 };
1929 let request = kranz_engine::standards_waiver::WaiverRequest {
1930 rule_id: rule.to_string(),
1931 revision,
1932 finding_subject: finding.map(str::to_string),
1933 reason: reason.to_string(),
1934 expires_at,
1935 };
1936 let outcome = match kranz_engine::standards_waiver::approve_standards_waiver(
1937 repo, mission_id, &request, "cli", force_lock,
1938 ) {
1939 Ok(outcome) => outcome,
1940 Err(kranz_engine::error::EngineError::LockHeld(e)) => {
1941 eprintln!(
1942 "waiver refused: an engine still holds mission '{mission_id}'s lock — stop \
1943 the running mission first (a waiver against a live mission would race the \
1944 runner's own appends).\n (underlying: {e})"
1945 );
1946 return Ok(1);
1947 }
1948 Err(e) => {
1949 eprintln!("waiver refused: {e}");
1950 return Ok(1);
1951 }
1952 };
1953 let pinned = &outcome.rule;
1956 println!(
1957 "recorded standards.waiver.approved (seq {})",
1958 outcome.event.seq
1959 );
1960 println!("mission: {mission_id}");
1961 println!(
1962 "rule: {} r{} — {}, {}; checker {}; waivable: {}",
1963 pinned.id,
1964 pinned.revision,
1965 pinned.level,
1966 pinned.effective_status,
1967 pinned.checker.as_deref().unwrap_or("-"),
1968 pinned.waivable
1969 );
1970 println!(" statement: {}", pinned.statement);
1971 println!(
1972 "finding: {} (run {})\n evidence: {}",
1973 outcome.finding_subject, outcome.run_id, outcome.finding_evidence
1974 );
1975 println!(" fingerprint: sha256:{}", outcome.finding_fingerprint);
1976 if pinned.when_paths.is_empty() {
1977 println!(
1978 "affected paths: the whole mission diff ({} path(s)) — the rule is unscoped",
1979 outcome.affected_paths.len()
1980 );
1981 } else if outcome.affected_paths.is_empty() {
1982 println!(
1983 "affected paths: (none — the rule's when-paths match no changed path; the \
1984 waiver binds the empty scoped diff)"
1985 );
1986 } else {
1987 println!("affected paths: {}", outcome.affected_paths.join(", "));
1988 }
1989 println!(
1990 "diff digest: sha256:{} (covers the affected-path diff at the mission branch tip)",
1991 outcome.diff_digest
1992 );
1993 println!(
1994 "approver: {} via cli\nreason: {reason}\nexpires: {}",
1995 kranz_engine::standards_waiver::LOCAL_OPERATOR,
1996 expires_at.to_rfc3339()
1997 );
1998 Ok(0)
1999}
2000
2001fn cmd_standards_attest(
2006 repo: &Path,
2007 mission_id: &str,
2008 rule: &str,
2009 reason: &str,
2010 force_lock: LockForce,
2011) -> Result<i32> {
2012 let record = match kranz_engine::standards_attestation::approve_attestation(
2013 repo, mission_id, rule, reason, "cli", force_lock,
2014 ) {
2015 Ok(record) => record,
2016 Err(kranz_engine::error::EngineError::LockHeld(e)) => {
2017 eprintln!(
2018 "attestation refused: an engine still holds mission '{mission_id}'s lock — stop \
2019 the running mission first.\n (underlying: {e})"
2020 );
2021 return Ok(1);
2022 }
2023 Err(e) => {
2024 eprintln!("attestation refused: {e}");
2025 return Ok(1);
2026 }
2027 };
2028 println!(
2029 "recorded standards.attestation.approved (seq {})",
2030 record.seq
2031 );
2032 println!("mission: {mission_id}");
2033 println!("rule: {} r{}", record.rule_id, record.rule_revision);
2034 if record.paths.is_empty() {
2035 println!("affected paths: (none)");
2036 } else {
2037 println!("affected paths: {}", record.paths.join(", "));
2038 }
2039 println!("diff digest: sha256:{}", record.diff_digest);
2040 println!(
2041 "approver: {} via {}\nreason: {}",
2042 record.approver, record.surface, record.reason
2043 );
2044 Ok(0)
2045}
2046
2047pub fn cmd_missions(repo: &Path) -> Result<String> {
2054 let index_contents =
2055 std::fs::read_to_string(MissionPaths::new(repo, "_").missions_dir().join("index.md"))
2056 .unwrap_or_default();
2057 let mut ids = MissionPaths::list_missions(repo);
2058 for id in mission_catalog::mission_index_ids(&index_contents) {
2059 if !ids.contains(&id) {
2060 ids.push(id);
2061 }
2062 }
2063 ids.sort();
2064 if ids.is_empty() {
2065 return Ok("no missions\n".to_string());
2066 }
2067 let ticket_of: std::collections::HashMap<String, String> =
2070 kranz_engine::ticket::Ticket::list(repo)
2071 .into_iter()
2072 .filter_map(|t| {
2073 kranz_engine::ticket::Ticket::mission_for(repo, &t.slug).map(|m| (m, t.slug))
2074 })
2075 .collect();
2076 let mut out = String::new();
2077 for id in ids {
2078 let ticket = ticket_of
2079 .get(&id)
2080 .map(|s| format!(" [ticket: {s}]"))
2081 .unwrap_or_default();
2082 let paths = MissionPaths::new(repo, &id);
2083 if !paths.events_file().is_file() {
2084 out.push_str(&format!(
2085 "{id} {:<10} deleted mission (no data recorded)\n",
2086 "DELETED"
2087 ));
2088 continue;
2089 }
2090 if let Err(error) = paths.require_no_follow() {
2093 out.push_str(&format!("{id} {:<10} (unreadable: {error})\n", "FAILED"));
2094 continue;
2095 }
2096 match load_state(repo, &id) {
2097 Ok(state) => out.push_str(&format!(
2098 "{id} {:<10} {}{ticket}\n",
2099 output::mission_status_label(state.mission.status),
2100 state.mission.goal
2101 )),
2102 Err(e) => out.push_str(&format!("{id} {:<10} (unreadable: {e:#})\n", "FAILED")),
2103 }
2104 }
2105 Ok(out)
2106}
2107
2108pub fn cmd_abandon(
2116 repo: &Path,
2117 mission_id: &str,
2118 reason: &str,
2119 force_lock: LockForce,
2120) -> Result<()> {
2121 require_mission(repo, mission_id)?;
2122 mission_catalog::abandon_mission(repo, mission_id, reason, force_lock).map_err(|e| {
2123 if matches!(e, kranz_engine::error::EngineError::LockHeld(_)) {
2124 anyhow!(
2125 "cannot abandon mission '{mission_id}' — an engine still holds its lock. \
2126 Stop the running `kranz run` first. If the holder is a crashed leftover, \
2127 pass --force-lock (steals unless the holder is provably alive); a provably \
2128 LIVE holder that you have verified to be a zombie or foreign process \
2129 additionally requires --dangerously-steal-live-lock.\n (underlying: {e})"
2130 )
2131 } else {
2132 anyhow::Error::new(e).context(format!("abandoning mission '{mission_id}'"))
2133 }
2134 })
2135}
2136
2137#[derive(Debug, Clone, PartialEq, Eq)]
2139pub struct CleanEntry {
2140 pub id: String,
2141 pub status_label: String,
2142 pub goal: String,
2143}
2144
2145pub fn select_cleanable(repo: &Path, all: bool) -> Vec<CleanEntry> {
2153 let mut out = Vec::new();
2154 for id in MissionPaths::list_missions(repo) {
2155 let paths = MissionPaths::new(repo, &id);
2156 if mission_catalog::mission_lock_is_live(&paths) {
2158 continue;
2159 }
2160 let Ok(state) = load_state(repo, &id) else {
2161 continue; };
2163 let has_plan = paths.plan_file().is_file();
2164 if mission_catalog::cleanable_class(state.mission.status, has_plan).is_cleaned(all) {
2165 out.push(CleanEntry {
2166 id,
2167 status_label: output::mission_status_label(state.mission.status).to_string(),
2168 goal: state.mission.goal,
2169 });
2170 }
2171 }
2172 out
2173}
2174
2175pub fn render_clean_listing(entries: &[CleanEntry]) -> String {
2178 if entries.is_empty() {
2179 return "nothing to clean\n".to_string();
2180 }
2181 let mut out = String::new();
2182 for e in entries {
2183 out.push_str(&format!("{:<10} {} {}\n", e.status_label, e.id, e.goal));
2184 }
2185 out
2186}
2187
2188fn cmd_clean(repo: &Path, yes: bool, all: bool) -> Result<i32> {
2194 let entries = select_cleanable(repo, all);
2195 if entries.is_empty() {
2196 print!("{}", render_clean_listing(&entries));
2197 return Ok(0);
2198 }
2199
2200 print!("{}", render_clean_listing(&entries));
2201 println!(
2202 "\n{} mission director{} above would be removed.{}",
2203 entries.len(),
2204 if entries.len() == 1 { "y" } else { "ies" },
2205 if all {
2206 ""
2207 } else {
2208 " (Complete missions are kept; pass --all to include them.)"
2209 }
2210 );
2211
2212 if !yes && !confirm_clean()? {
2213 println!("clean aborted; nothing removed.");
2214 return Ok(0);
2215 }
2216
2217 let removed = remove_missions(repo, &entries, true);
2218 println!(
2219 "cleaned {} mission director{}",
2220 removed.len(),
2221 if removed.len() == 1 { "y" } else { "ies" }
2222 );
2223 Ok(0)
2224}
2225
2226pub fn remove_missions(repo: &Path, entries: &[CleanEntry], verbose: bool) -> Vec<String> {
2232 let mut removed = Vec::new();
2233 for e in entries {
2234 let paths = MissionPaths::new(repo, &e.id);
2235 if mission_catalog::mission_lock_is_live(&paths) {
2241 eprintln!("kranz: skipping {} — became live since listing", e.id);
2242 continue;
2243 }
2244 let dir = paths.mission_dir();
2245 match std::fs::remove_dir_all(&dir) {
2246 Ok(()) => {
2247 if verbose {
2248 println!("removed {}", dir.display());
2249 }
2250 mission_catalog::prune_mission_index_file(repo, &e.id);
2251 removed.push(e.id.clone());
2252 }
2253 Err(err) => eprintln!("kranz: could not remove {}: {err}", dir.display()),
2254 }
2255 }
2256 removed
2257}
2258
2259fn confirm_clean() -> Result<bool> {
2262 use std::io::BufRead;
2263 print!("proceed? [y/N] ");
2264 std::io::stdout().flush().ok();
2265 let mut line = String::new();
2266 let n = std::io::stdin().lock().read_line(&mut line)?;
2267 if n == 0 {
2268 return Ok(false); }
2270 Ok(matches!(
2271 line.trim().to_ascii_lowercase().as_str(),
2272 "y" | "yes"
2273 ))
2274}
2275
2276pub(crate) fn refuse_non_loopback_without_insecure_lan(
2286 bind: std::net::IpAddr,
2287 insecure_lan: bool,
2288) -> Result<()> {
2289 if !bind.is_loopback() && !insecure_lan {
2290 anyhow::bail!(
2291 "refusing to bind {bind}: non-loopback binds expose the API on the \
2292 network (reads require the read-only or mutation token; POSTs \
2293 require the mutation token). \
2294 Re-run with `--insecure-lan` if you intentionally trust this \
2295 network (LAN/tailnet), or keep the default `--host 127.0.0.1`."
2296 );
2297 }
2298 Ok(())
2299}
2300
2301pub(crate) fn effective_require_read_token(bind_is_loopback: bool, read_auth: bool) -> bool {
2307 !bind_is_loopback || read_auth
2308}
2309
2310#[allow(clippy::too_many_arguments)]
2320async fn cmd_serve(
2321 repo: PathBuf,
2322 host: String,
2323 port: u16,
2324 insecure_lan: bool,
2325 read_auth: bool,
2326 open: bool,
2327 dashboard: Option<PathBuf>,
2328 token: Option<String>,
2329 read_token: Option<String>,
2330 slack: bool,
2331) -> Result<i32> {
2332 let bind: std::net::IpAddr = host
2333 .parse()
2334 .map_err(|e| anyhow!("--host '{host}' is not an IP address: {e}"))?;
2335 refuse_non_loopback_without_insecure_lan(bind, insecure_lan)?;
2336 let token = token
2337 .or_else(|| std::env::var("KRANZ_TOKEN").ok())
2338 .unwrap_or_else(kranz_server::generate_token);
2339 let read_token = read_token
2340 .or_else(|| std::env::var("KRANZ_READ_TOKEN").ok())
2341 .unwrap_or_else(kranz_server::generate_token);
2342 kranz_server::MutationAuthority::new(token.clone())
2346 .context("--token / KRANZ_TOKEN is invalid")?;
2347 anyhow::ensure!(
2348 !read_token.is_empty() && read_token.bytes().all(|byte| byte.is_ascii_graphic()),
2349 "--read-token / KRANZ_READ_TOKEN must be non-empty visible ASCII without whitespace"
2350 );
2351 anyhow::ensure!(
2352 read_token != token,
2353 "--read-token / KRANZ_READ_TOKEN must differ from the mutation token"
2354 );
2355 if !bind.is_loopback() {
2356 eprintln!(
2357 "WARNING: binding {bind} with --insecure-lan — the API is reachable \
2358 beyond this machine. Reads require the read-only or mutation token; \
2359 POSTs require the mutation token. Use only on a network you trust."
2360 );
2361 }
2362 if read_auth && effective_require_read_token(bind.is_loopback(), read_auth) {
2363 eprintln!(
2364 "--read-auth: GETs and the WS upgrade now require the read-only or \
2365 mutation token, including on loopback; POSTs still require mutation \
2366 authority."
2367 );
2368 }
2369 let listener = kranz_server::bind_listener(bind, port)
2372 .await
2373 .map_err(|e| anyhow!("failed to bind {bind}:{port}: {e}"))?;
2374 let local_addr = listener
2375 .local_addr()
2376 .map_err(|e| anyhow!("failed to read bound address: {e}"))?;
2377 let multi_host = Arc::new(kranz_server::MultiRepoHost::from_global_config(
2381 kranz_engine::paths::global_config().as_deref(),
2382 repo.clone(),
2383 )?);
2384 multi_host.ensure_auto_work_started();
2387
2388 if slack {
2392 if multi_host.uses_operator_catalog() {
2393 let catalog = multi_repo_slack_catalog(&multi_host)?;
2394 tokio::spawn(async move {
2395 let never = std::future::pending::<()>();
2396 if let Err(error) = kranz_slack::serve_slack_catalog(catalog, never).await {
2397 tracing::error!(%error, "slack catalog bridge exited with an error");
2398 }
2399 });
2400 } else {
2401 let context = single_repo_slack_context(&multi_host)?;
2402 let repo_slack = context.root().to_path_buf();
2403 let hosted = context.host().ok_or_else(|| {
2404 anyhow!(
2405 "cannot start Slack bridge for unavailable repository '{}': {}",
2406 context.id(),
2407 context.unavailable_reason().unwrap_or("unavailable")
2408 )
2409 })?;
2410 let bridge_host: kranz_slack::SharedHost =
2411 Arc::new(crate::host_bridge::HostedPlanning(hosted.clone()));
2412 tokio::spawn(async move {
2413 let never = std::future::pending::<()>();
2417 if let Err(e) =
2418 kranz_slack::serve_slack(&repo_slack, Some(bridge_host), never).await
2419 {
2420 tracing::error!(error = %e, "slack bridge exited with an error");
2421 }
2422 });
2423 }
2424 }
2425
2426 let dashboard_assets = resolve_dashboard_assets(&repo, dashboard);
2427 let display_host = match local_addr.ip() {
2430 std::net::IpAddr::V6(v6) => format!("[{v6}]"),
2431 std::net::IpAddr::V4(v4) => v4.to_string(),
2432 };
2433 let url = format!("http://{display_host}:{}/", local_addr.port());
2434 println!("kranz server on {url}");
2435 println!("mutation token: {token}");
2436 println!("read token: {read_token} (GETs/WS only — safe for dashboards and agents)");
2437 if open {
2438 println!(
2439 "opening the bare URL; no token rides in the opener's argv. Paste the \
2440 mutation token above when the dashboard asks for one"
2441 );
2442 }
2443 match &dashboard_assets {
2444 Some(DashboardAssets::Embedded) => println!(
2445 "serving embedded dashboard ({})",
2446 crate::embedded_dashboard::EMBEDDED_DASHBOARD_SOURCE
2447 ),
2448 Some(DashboardAssets::Dir(dir)) => println!("serving dashboard from {}", dir.display()),
2449 None => println!(
2450 "no dashboard build found (--dashboard, $KRANZ_DASHBOARD_DIST, \
2451 <repo>/apps/dashboard/dist, installed asset dirs, or the kranz checkout) \
2452 and no embedded dashboard is available; serving API only"
2453 ),
2454 }
2455
2456 let static_assets = dashboard_assets.map(|assets| match assets {
2457 DashboardAssets::Dir(dir) => kranz_server::DashboardStatic::Dir(dir),
2458 DashboardAssets::Embedded => {
2459 kranz_server::DashboardStatic::Embedded(crate::embedded_dashboard::EMBEDDED_DASHBOARD)
2460 }
2461 });
2462
2463 if open {
2464 let url = url.clone();
2489 tokio::spawn(async move {
2490 tokio::time::sleep(Duration::from_millis(600)).await;
2491 open_browser(&url);
2492 });
2493 }
2494
2495 let shutdown = async {
2496 if let Err(e) = tokio::signal::ctrl_c().await {
2497 tracing::error!(error = %e, "failed to install ctrl-c handler");
2498 }
2499 };
2500 let result = serve_multi_with_token_cleanup(
2501 &repo,
2502 multi_host,
2503 listener,
2504 static_assets,
2505 token,
2506 read_token,
2507 read_auth,
2508 shutdown,
2509 )
2510 .await;
2511 match result {
2512 Ok(()) => Ok(0),
2513 Err(e) => Err(anyhow!("server failed: {e}")),
2514 }
2515}
2516
2517fn single_repo_slack_context(
2518 multi_host: &kranz_server::MultiRepoHost,
2519) -> Result<Arc<kranz_server::RepoContext>> {
2520 multi_host
2521 .compatibility_context()
2522 .ok_or_else(|| anyhow!("--slack requires one healthy configured repository"))
2523}
2524
2525fn multi_repo_slack_catalog(
2526 multi_host: &kranz_server::MultiRepoHost,
2527) -> Result<kranz_slack::SlackCatalog> {
2528 let global_config = kranz_engine::paths::global_config()
2529 .ok_or_else(|| anyhow!("cannot locate the operator config directory for Slack affinity"))?;
2530 let affinity_path = global_config
2531 .parent()
2532 .expect("global config has a parent")
2533 .join("slack")
2534 .join("thread-affinity.json");
2535 let repos = multi_host
2536 .contexts()
2537 .map(|context| {
2538 let host = context.host().map(|host| {
2539 Arc::new(crate::host_bridge::HostedPlanning(host.clone()))
2540 as kranz_slack::SharedHost
2541 });
2542 kranz_slack::SlackRepo {
2543 id: context.id().to_string(),
2544 root: context.root().to_path_buf(),
2545 display_name: context
2546 .config()
2547 .display_name
2548 .clone()
2549 .unwrap_or_else(|| context.id().to_string()),
2550 routes: context
2551 .config()
2552 .slack
2553 .channels
2554 .iter()
2555 .map(|route| kranz_slack::SlackRoute {
2556 team_id: route.team.clone(),
2557 channel_id: route.channel.clone(),
2558 })
2559 .collect(),
2560 allow_users: context.config().slack.allow_users.clone(),
2561 available: context.is_healthy(),
2562 host,
2563 unavailable_reason: context.unavailable_reason().map(str::to_string),
2564 default: multi_host.default_repo() == Some(context.id()),
2565 }
2566 })
2567 .collect();
2568 kranz_slack::SlackCatalog::new(repos, affinity_path)
2569}
2570
2571#[allow(clippy::too_many_arguments)]
2572async fn serve_multi_with_token_cleanup(
2573 repo: &Path,
2574 multi_host: Arc<kranz_server::MultiRepoHost>,
2575 listener: tokio::net::TcpListener,
2576 static_assets: Option<kranz_server::DashboardStatic>,
2577 token: String,
2578 read_token: String,
2579 read_auth: bool,
2580 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
2581) -> anyhow::Result<()> {
2582 let (token_file, read_token_file) = if multi_host.uses_operator_catalog() {
2583 let address = listener
2584 .local_addr()
2585 .context("cannot resolve the bound address for operator token storage")?;
2586 let token_file = write_operator_serve_token(address, &token)
2587 .context("cannot securely store the operator serve token")?;
2588 let read_token_file = write_operator_serve_read_token(address, &read_token)
2589 .context("cannot securely store the operator serve read token")?;
2590 (token_file, read_token_file)
2591 } else {
2592 let token_file =
2593 write_serve_token(repo, &token).context("cannot securely store the serve token")?;
2594 let read_token_file = write_serve_read_token(repo, &read_token)
2595 .context("cannot securely store the serve read token")?;
2596 (token_file, read_token_file)
2597 };
2598 let result = kranz_server::serve_multi_on_listener(
2599 multi_host,
2600 listener,
2601 static_assets,
2602 kranz_server::MutationAuthority::new(token)?,
2603 Some(read_token),
2604 read_auth,
2605 shutdown,
2606 )
2607 .await;
2608 remove_token_file(&token_file);
2609 remove_token_file(&read_token_file);
2610 result
2611}
2612
2613#[cfg(test)]
2619async fn serve_with_token_cleanup(
2620 repo: &Path,
2621 host: Arc<kranz_server::MissionHost>,
2622 listener: tokio::net::TcpListener,
2623 static_assets: Option<kranz_server::DashboardStatic>,
2624 token: String,
2625 read_token: String,
2626 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
2627) -> anyhow::Result<()> {
2628 let token_file = write_serve_token(repo, &token)?;
2632 let read_token_file = write_serve_read_token(repo, &read_token)?;
2633
2634 let result = kranz_server::serve_on_listener(
2635 host,
2636 listener,
2637 static_assets,
2638 kranz_server::MutationAuthority::new(token)?,
2639 shutdown,
2640 )
2641 .await;
2642
2643 remove_token_file(&token_file);
2644 remove_token_file(&read_token_file);
2645
2646 result
2647}
2648
2649fn write_serve_token(repo: &Path, token: &str) -> std::io::Result<PathBuf> {
2655 write_token_file(&repo.join(".kranz").join("serve.token"), token)
2656}
2657
2658fn write_serve_read_token(repo: &Path, read_token: &str) -> std::io::Result<PathBuf> {
2664 write_token_file(&repo.join(".kranz").join("serve.read.token"), read_token)
2665}
2666
2667fn write_operator_serve_token(
2672 address: std::net::SocketAddr,
2673 token: &str,
2674) -> std::io::Result<PathBuf> {
2675 let global = kranz_engine::paths::global_config().ok_or_else(|| {
2676 std::io::Error::new(
2677 std::io::ErrorKind::NotFound,
2678 "cannot resolve operator config directory",
2679 )
2680 })?;
2681 let path = operator_serve_token_path(&global, address);
2682 write_token_file(&path, token)
2683}
2684
2685fn write_operator_serve_read_token(
2687 address: std::net::SocketAddr,
2688 read_token: &str,
2689) -> std::io::Result<PathBuf> {
2690 let global = kranz_engine::paths::global_config().ok_or_else(|| {
2691 std::io::Error::new(
2692 std::io::ErrorKind::NotFound,
2693 "cannot resolve operator config directory",
2694 )
2695 })?;
2696 let path = operator_serve_read_token_path(&global, address);
2697 write_token_file(&path, read_token)
2698}
2699
2700fn operator_serve_read_token_path(global_config: &Path, address: std::net::SocketAddr) -> PathBuf {
2702 operator_serve_token_path(global_config, address).with_extension("read.token")
2703}
2704
2705fn operator_serve_token_path(global_config: &Path, address: std::net::SocketAddr) -> PathBuf {
2706 let endpoint = match address.ip() {
2707 std::net::IpAddr::V4(ip) => format!("v4-{:08x}-{}", u32::from(ip), address.port()),
2708 std::net::IpAddr::V6(ip) => format!("v6-{:032x}-{}", u128::from(ip), address.port()),
2709 };
2710 global_config
2711 .parent()
2712 .unwrap_or_else(|| Path::new("."))
2713 .join("serve")
2714 .join(format!("{endpoint}.token"))
2715}
2716
2717fn legacy_operator_serve_token_path(global_config: &Path, port: u16) -> PathBuf {
2721 global_config
2722 .parent()
2723 .unwrap_or_else(|| Path::new("."))
2724 .join("serve")
2725 .join(format!("{port}.token"))
2726}
2727
2728fn write_token_file(path: &Path, token: &str) -> std::io::Result<PathBuf> {
2729 let dir = path.parent().ok_or_else(|| {
2730 std::io::Error::new(std::io::ErrorKind::InvalidInput, "token path has no parent")
2731 })?;
2732 std::fs::create_dir_all(dir)?;
2733 let tmp = dir.join(format!(
2738 ".{}.tmp-{}",
2739 path.file_name()
2740 .and_then(|name| name.to_str())
2741 .unwrap_or("serve.token"),
2742 uuid::Uuid::new_v4().as_simple()
2743 ));
2744 let write_tmp = || -> std::io::Result<()> {
2745 let mut options = std::fs::OpenOptions::new();
2746 options.create_new(true).write(true);
2747 #[cfg(unix)]
2748 {
2749 use std::os::unix::fs::OpenOptionsExt;
2750 options.mode(0o600);
2751 }
2752 let mut file = options.open(&tmp)?;
2753 file.write_all(token.as_bytes())?;
2754 file.flush()?;
2755 Ok(())
2756 };
2757 if let Err(error) = write_tmp() {
2758 let _ = std::fs::remove_file(&tmp);
2759 return Err(error);
2760 }
2761 #[cfg(unix)]
2762 {
2763 use std::os::unix::fs::PermissionsExt;
2764 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2765 }
2766 if let Err(error) = std::fs::rename(&tmp, path) {
2767 let _ = std::fs::remove_file(&tmp);
2768 return Err(error);
2769 }
2770 Ok(path.to_path_buf())
2771}
2772
2773fn remove_token_file(path: &Path) {
2774 if let Err(err) = std::fs::remove_file(path) {
2775 if err.kind() != std::io::ErrorKind::NotFound {
2776 eprintln!("kranz: could not remove {}: {err}", path.display());
2777 }
2778 }
2779}
2780
2781#[derive(Debug, Clone, PartialEq, Eq)]
2782enum DashboardAssets {
2783 Dir(PathBuf),
2784 Embedded,
2785}
2786
2787#[derive(Debug, Clone, Default)]
2788struct DashboardResolutionInputs {
2789 env_dist: Option<PathBuf>,
2790 home: Option<PathBuf>,
2791 exe: Option<PathBuf>,
2792 manifest_dir: Option<PathBuf>,
2793 embedded_available: bool,
2794}
2795
2796impl DashboardResolutionInputs {
2797 fn runtime() -> Self {
2798 Self {
2799 env_dist: std::env::var_os("KRANZ_DASHBOARD_DIST").map(PathBuf::from),
2800 home: std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
2801 .map(PathBuf::from),
2802 exe: std::env::current_exe().ok(),
2803 manifest_dir: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))),
2804 embedded_available: !crate::embedded_dashboard::EMBEDDED_DASHBOARD.is_empty(),
2805 }
2806 }
2807}
2808
2809fn resolve_dashboard_assets(repo: &Path, explicit: Option<PathBuf>) -> Option<DashboardAssets> {
2810 resolve_dashboard_assets_from(repo, explicit, &DashboardResolutionInputs::runtime())
2811}
2812
2813fn resolve_dashboard_assets_from(
2821 repo: &Path,
2822 explicit: Option<PathBuf>,
2823 inputs: &DashboardResolutionInputs,
2824) -> Option<DashboardAssets> {
2825 if let Some(d) = explicit {
2826 return Some(DashboardAssets::Dir(d));
2829 }
2830
2831 if let Some(d) = first_dashboard_dir(dashboard_dir_candidates(repo, inputs)) {
2832 return Some(DashboardAssets::Dir(d));
2833 }
2834
2835 inputs
2836 .embedded_available
2837 .then_some(DashboardAssets::Embedded)
2838}
2839
2840pub fn resolve_dashboard_dist(repo: &Path, explicit: Option<PathBuf>) -> Option<PathBuf> {
2843 match resolve_dashboard_assets(repo, explicit) {
2844 Some(DashboardAssets::Dir(dir)) => Some(dir),
2845 Some(DashboardAssets::Embedded) | None => None,
2846 }
2847}
2848
2849fn dashboard_dir_candidates(repo: &Path, inputs: &DashboardResolutionInputs) -> Vec<PathBuf> {
2850 let mut candidates = Vec::new();
2851
2852 candidates.extend(inputs.env_dist.clone());
2853 candidates.push(repo.join("apps").join("dashboard").join("dist"));
2854
2855 if let Some(home) = &inputs.home {
2856 candidates.push(home.join(".kranz").join("dashboard").join("dist"));
2857 candidates.push(home.join(".kranz").join("dashboard"));
2858 }
2859
2860 if let Some(exe) = &inputs.exe {
2861 candidates.extend(installed_dashboard_dirs(exe));
2862 candidates.extend(source_checkout_dist_from_exe(exe));
2863 }
2864
2865 if let Some(manifest_dir) = &inputs.manifest_dir {
2866 candidates.extend(source_checkout_dist_from_manifest(manifest_dir));
2867 candidates.push(manifest_dir.join("assets").join("dashboard").join("dist"));
2868 }
2869
2870 candidates
2871}
2872
2873fn first_dashboard_dir(candidates: Vec<PathBuf>) -> Option<PathBuf> {
2874 candidates
2875 .into_iter()
2876 .find(|d| d.join("index.html").is_file())
2877}
2878
2879fn installed_dashboard_dirs(exe: &Path) -> Vec<PathBuf> {
2880 let Some(bin_dir) = exe.parent() else {
2881 return Vec::new();
2882 };
2883 let mut dirs = vec![
2884 bin_dir.join("dashboard").join("dist"),
2885 bin_dir.join("dashboard"),
2886 ];
2887 if let Some(prefix) = bin_dir.parent() {
2888 dirs.push(
2889 prefix
2890 .join("share")
2891 .join("kranz")
2892 .join("dashboard")
2893 .join("dist"),
2894 );
2895 dirs.push(prefix.join("share").join("kranz").join("dashboard"));
2896 }
2897 dirs
2898}
2899
2900fn source_checkout_dist_from_exe(exe: &Path) -> Option<PathBuf> {
2901 let profile_dir = exe.parent()?;
2902 let target_dir = profile_dir.parent()?;
2903 if target_dir.file_name()? != "target" {
2904 return None;
2905 }
2906 Some(
2907 target_dir
2908 .parent()?
2909 .join("apps")
2910 .join("dashboard")
2911 .join("dist"),
2912 )
2913}
2914
2915fn source_checkout_dist_from_manifest(manifest_dir: &Path) -> Option<PathBuf> {
2916 Some(
2917 manifest_dir
2918 .parent()?
2919 .parent()?
2920 .join("apps")
2921 .join("dashboard")
2922 .join("dist"),
2923 )
2924}
2925
2926fn open_browser(url: &str) {
2928 #[cfg(target_os = "macos")]
2929 let mut command = {
2930 let mut c = std::process::Command::new("open");
2931 c.arg(url);
2932 c
2933 };
2934 #[cfg(target_os = "windows")]
2935 let mut command = {
2936 let mut c = std::process::Command::new("cmd");
2937 c.args(["/C", "start", "", url]);
2939 c
2940 };
2941 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2942 let mut command = {
2943 let mut c = std::process::Command::new("xdg-open");
2944 c.arg(url);
2945 c
2946 };
2947
2948 match command.spawn() {
2949 Ok(mut child) => {
2950 std::thread::spawn(move || {
2952 let _ = child.wait();
2953 });
2954 }
2955 Err(e) => eprintln!("kranz: could not open the browser: {e}"),
2956 }
2957}
2958
2959fn resolve_release_token(
2971 repo: &Path,
2972 url: &str,
2973 flag: Option<String>,
2974) -> Result<String, ReleaseTokenError> {
2975 let parsed = reqwest::Url::parse(url).ok();
2976 let operator_lookup = parsed
2977 .as_ref()
2978 .and_then(|url| {
2979 kranz_engine::paths::global_config()
2980 .as_deref()
2981 .map(|global| operator_token_for_url(global, url))
2982 })
2983 .unwrap_or(OperatorTokenLookup::Absent);
2984 let allow_repo_fallback = parsed.as_ref().is_some_and(automatic_repo_token_allowed);
2985 resolve_release_token_from_sources(repo, operator_lookup, allow_repo_fallback, flag)
2986}
2987
2988#[derive(Debug, Clone, PartialEq, Eq)]
2993enum OperatorTokenLookup {
2994 Absent,
2995 Found(String),
2996 Legacy(String),
2997 Ambiguous,
2998}
2999
3000#[derive(Debug, Clone, PartialEq, Eq)]
3001enum ReleaseTokenError {
3002 Absent,
3003 Ambiguous,
3004}
3005
3006fn resolve_release_token_from_sources(
3007 repo: &Path,
3008 operator_lookup: OperatorTokenLookup,
3009 allow_repo_fallback: bool,
3010 flag: Option<String>,
3011) -> Result<String, ReleaseTokenError> {
3012 if let Some(flag) = flag {
3013 return Ok(flag);
3014 }
3015 if let Ok(env) = std::env::var("KRANZ_TOKEN") {
3016 return Ok(env);
3017 }
3018 match operator_lookup {
3019 OperatorTokenLookup::Found(authority) => Ok(authority),
3020 OperatorTokenLookup::Legacy(authority) => {
3025 let repo_authority = allow_repo_fallback
3026 .then(|| read_token_file(&repo.join(".kranz").join("serve.token")))
3027 .flatten();
3028 Ok(repo_authority.unwrap_or(authority))
3029 }
3030 OperatorTokenLookup::Ambiguous => Err(ReleaseTokenError::Ambiguous),
3031 OperatorTokenLookup::Absent => allow_repo_fallback
3032 .then(|| read_token_file(&repo.join(".kranz").join("serve.token")))
3033 .flatten()
3034 .ok_or(ReleaseTokenError::Absent),
3035 }
3036}
3037
3038fn scan_operator_token_addresses(
3039 global_config: &Path,
3040 addresses: &[std::net::SocketAddr],
3041) -> OperatorTokenLookup {
3042 let mut authority = None;
3043 for address in addresses {
3044 if let Some(found) = read_token_file(&operator_serve_token_path(global_config, *address)) {
3045 if authority.is_some() {
3046 return OperatorTokenLookup::Ambiguous;
3049 }
3050 authority = Some(found);
3051 }
3052 }
3053 match authority {
3054 Some(authority) => OperatorTokenLookup::Found(authority),
3055 None => OperatorTokenLookup::Absent,
3056 }
3057}
3058
3059fn operator_token_for_url(global_config: &Path, url: &reqwest::Url) -> OperatorTokenLookup {
3060 let Some(port) = url.port_or_known_default() else {
3061 return OperatorTokenLookup::Absent;
3062 };
3063 let Some(host) = normalized_url_host(url) else {
3064 return OperatorTokenLookup::Absent;
3065 };
3066 let endpoint_lookup = if host.eq_ignore_ascii_case("localhost") {
3070 let primary = [
3071 std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)),
3072 std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, port)),
3073 ];
3074 match scan_operator_token_addresses(global_config, &primary) {
3075 OperatorTokenLookup::Absent => scan_operator_token_addresses(
3076 global_config,
3077 &[
3078 std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, port)),
3079 std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, port)),
3080 ],
3081 ),
3082 other => other,
3083 }
3084 } else {
3085 let Ok(ip) = host.parse::<std::net::IpAddr>() else {
3086 return OperatorTokenLookup::Absent;
3087 };
3088 if !ip.is_loopback() {
3091 return OperatorTokenLookup::Absent;
3092 }
3093 let primary = [std::net::SocketAddr::new(ip, port)];
3094 match scan_operator_token_addresses(global_config, &primary) {
3095 OperatorTokenLookup::Absent => {
3096 let unspecified = match ip {
3097 std::net::IpAddr::V4(_) => {
3098 std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
3099 }
3100 std::net::IpAddr::V6(_) => {
3101 std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
3102 }
3103 };
3104 scan_operator_token_addresses(
3105 global_config,
3106 &[std::net::SocketAddr::new(unspecified, port)],
3107 )
3108 }
3109 other => other,
3110 }
3111 };
3112 match endpoint_lookup {
3113 OperatorTokenLookup::Absent => {
3114 match read_token_file(&legacy_operator_serve_token_path(global_config, port)) {
3116 Some(token) => OperatorTokenLookup::Legacy(token),
3117 None => OperatorTokenLookup::Absent,
3118 }
3119 }
3120 other => other,
3121 }
3122}
3123
3124fn automatic_repo_token_allowed(url: &reqwest::Url) -> bool {
3125 normalized_url_host(url).is_some_and(|host| {
3126 host.eq_ignore_ascii_case("localhost")
3127 || host
3128 .parse::<std::net::IpAddr>()
3129 .is_ok_and(|ip| ip.is_loopback())
3130 })
3131}
3132
3133fn normalized_url_host(url: &reqwest::Url) -> Option<&str> {
3134 let host = url.host_str()?;
3135 Some(
3136 host.strip_prefix('[')
3137 .and_then(|host| host.strip_suffix(']'))
3138 .unwrap_or(host),
3139 )
3140}
3141
3142fn read_token_file(path: &Path) -> Option<String> {
3143 let contents = std::fs::read_to_string(path).ok()?;
3144 let authority = contents.trim_end().to_string();
3145 if authority.is_empty() {
3146 None
3147 } else {
3148 Some(authority)
3149 }
3150}
3151
3152fn release_http_client() -> Result<reqwest::Client> {
3153 reqwest::Client::builder()
3154 .redirect(reqwest::redirect::Policy::none())
3155 .connect_timeout(Duration::from_secs(5))
3156 .timeout(Duration::from_secs(30))
3157 .build()
3158 .context("building release HTTP client")
3159}
3160
3161fn release_repo_id_is_valid(id: &str) -> bool {
3162 let mut chars = id.chars();
3163 let valid_first = chars.next().is_some_and(|ch| ch.is_ascii_alphanumeric());
3164 let valid_rest = chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'));
3165 valid_first && valid_rest
3166}
3167
3168async fn cmd_release(
3169 repo: &Path,
3170 mission_id: &str,
3171 url: &str,
3172 token: Option<String>,
3173) -> Result<i32> {
3174 let token = match resolve_release_token(repo, url, token) {
3175 Ok(token) => token,
3176 Err(ReleaseTokenError::Ambiguous) => {
3177 bail!(
3178 "multiple ~/.kranz/serve/<endpoint>.token files match {url}; \
3179 pass --token for the intended serve instead of guessing"
3180 );
3181 }
3182 Err(ReleaseTokenError::Absent) => {
3183 bail!(
3184 "no mutation token available — pass --token, set $KRANZ_TOKEN, or use a local URL matching \
3185 a live ~/.kranz/serve/<endpoint>.token / single-repo .kranz/serve.token \
3186 (the token `kranz serve` prints on startup)"
3187 );
3188 }
3189 };
3190
3191 let client = release_http_client()?;
3192 let repo_id = resolve_release_repo_id(repo, url, &client, &token).await?;
3193 if let Some(id) = repo_id.as_deref() {
3194 if !release_repo_id_is_valid(id) {
3195 bail!("live serve returned an invalid repository id '{id}'; refusing release");
3196 }
3197 }
3198 let endpoint = release_endpoint(url, mission_id, repo_id.as_deref());
3199
3200 let response = client
3201 .post(&endpoint)
3202 .header("x-kranz-token", token)
3203 .json(&serde_json::json!({}))
3204 .send()
3205 .await
3206 .map_err(|e| {
3207 if e.is_connect() {
3208 anyhow!("no kranz serve reachable at {url} — is it running?")
3209 } else {
3210 anyhow::Error::new(e).context(format!("releasing mission '{mission_id}'"))
3211 }
3212 })?;
3213
3214 match response.status() {
3215 reqwest::StatusCode::OK => {
3216 let body: serde_json::Value = response.json().await.unwrap_or_default();
3217 let released = body
3218 .get("released")
3219 .and_then(serde_json::Value::as_bool)
3220 .unwrap_or(false);
3221 if released {
3222 println!("mission {mission_id} released — the lock is now free");
3223 } else {
3224 println!("mission {mission_id} was already free (no lock held)");
3225 }
3226 Ok(0)
3227 }
3228 reqwest::StatusCode::CONFLICT => {
3229 eprintln!("kranz: mission {mission_id} has a turn in flight — try again shortly");
3230 Ok(1)
3231 }
3232 reqwest::StatusCode::NOT_FOUND => {
3233 eprintln!("kranz: unknown mission '{mission_id}' at {url}");
3234 Ok(1)
3235 }
3236 reqwest::StatusCode::UNAUTHORIZED => {
3237 eprintln!(
3238 "kranz: the token was missing or invalid — check --token / $KRANZ_TOKEN \
3239 against the token `kranz serve` printed on startup"
3240 );
3241 Ok(1)
3242 }
3243 other => {
3244 let body = response.text().await.unwrap_or_default();
3245 eprintln!("kranz: release failed ({other}): {body}");
3246 Ok(1)
3247 }
3248 }
3249}
3250
3251async fn resolve_release_repo_id(
3256 repo: &Path,
3257 url: &str,
3258 client: &reqwest::Client,
3259 token: &str,
3260) -> Result<Option<String>> {
3261 live_release_repo_id(repo, url, client, token).await
3262}
3263
3264fn release_repo_id_from_summaries(
3265 repo: &Path,
3266 repos: &[kranz_server::RepoSummary],
3267) -> Result<Option<String>> {
3268 if repos.is_empty() {
3269 bail!("the live serve returned an empty repository catalog; refusing an unscoped release");
3270 }
3271 let root = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
3272 let root_str = root.to_string_lossy();
3273 let matches: Vec<&kranz_server::RepoSummary> = repos
3274 .iter()
3275 .filter(|entry| {
3276 let entry_root = PathBuf::from(&entry.root);
3277 let entry_canon =
3278 std::fs::canonicalize(&entry_root).unwrap_or_else(|_| entry_root.clone());
3279 entry_canon == root || entry.root == root_str
3280 })
3281 .collect();
3282 match matches.as_slice() {
3283 [one] => Ok(Some(one.id.clone())),
3284 [] => Err(anyhow!(
3285 "selected repository '{}' is not present in the live serve catalog; refusing an unscoped release",
3286 repo.display()
3287 )),
3288 _ => Err(anyhow!(
3289 "selected repository '{}' matches multiple live serve catalog entries; refusing release",
3290 repo.display()
3291 )),
3292 }
3293}
3294
3295async fn live_release_repo_id(
3296 repo: &Path,
3297 url: &str,
3298 client: &reqwest::Client,
3299 token: &str,
3300) -> Result<Option<String>> {
3301 let base = url.trim_end_matches('/');
3302 let mut request = client.get(format!("{base}/api/repos"));
3306 let loopback_catalog = reqwest::Url::parse(url)
3307 .ok()
3308 .is_some_and(|parsed| automatic_repo_token_allowed(&parsed));
3309 if !loopback_catalog {
3310 request = request.header("x-kranz-token", token);
3311 }
3312 let send_error = |e: reqwest::Error| {
3313 if e.is_connect() {
3314 anyhow!("no kranz serve reachable at {url} — is it running?")
3315 } else {
3316 anyhow::Error::new(e).context("listing live serve repositories")
3317 }
3318 };
3319 let mut response = request.send().await.map_err(send_error)?;
3320 if loopback_catalog && response.status() == reqwest::StatusCode::UNAUTHORIZED {
3325 response = client
3326 .get(format!("{base}/api/repos"))
3327 .header("x-kranz-token", token)
3328 .send()
3329 .await
3330 .map_err(send_error)?;
3331 }
3332 if !response.status().is_success() {
3333 bail!(
3334 "cannot list repositories at {url}: HTTP {}",
3335 response.status()
3336 );
3337 }
3338 let repos: Vec<kranz_server::RepoSummary> = response
3339 .json()
3340 .await
3341 .context("parsing GET /api/repos response")?;
3342 release_repo_id_from_summaries(repo, &repos)
3343}
3344
3345fn release_endpoint(url: &str, mission_id: &str, repo_id: Option<&str>) -> String {
3346 let base = url.trim_end_matches('/');
3347 match repo_id {
3348 Some(repo_id) => {
3349 format!("{base}/api/repos/{repo_id}/missions/{mission_id}/release")
3350 }
3351 None => format!("{base}/api/missions/{mission_id}/release"),
3352 }
3353}
3354
3355#[cfg(test)]
3356mod tests {
3357 use super::*;
3358 use std::fs;
3359
3360 static KRANZ_TOKEN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3365
3366 #[tokio::test]
3369 #[allow(clippy::await_holding_lock)]
3373 async fn release_without_a_token_errors_clearly() {
3374 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3375 unsafe {
3377 std::env::remove_var("KRANZ_TOKEN");
3378 }
3379 let tmp = tempfile::tempdir().unwrap();
3380 let repo = tmp.path().to_path_buf();
3381 let err = cmd_release(&repo, "m-1", "http://192.0.2.1:4560", None)
3385 .await
3386 .unwrap_err();
3387 let msg = err.to_string();
3388 assert!(msg.contains("--token"), "{msg}");
3389 assert!(msg.contains("KRANZ_TOKEN"), "{msg}");
3390 }
3391
3392 #[test]
3395 fn release_reads_token_file_when_flag_and_env_absent() {
3396 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3397 unsafe {
3399 std::env::remove_var("KRANZ_TOKEN");
3400 }
3401 let tmp = tempfile::tempdir().unwrap();
3402 let repo = tmp.path().to_path_buf();
3403 write_serve_token(&repo, "file-token").unwrap();
3404
3405 assert_eq!(
3406 resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None),
3407 Ok("file-token".to_string())
3408 );
3409 }
3410
3411 #[test]
3412 fn release_reads_token_file_but_flag_overrides() {
3413 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3414 unsafe {
3416 std::env::remove_var("KRANZ_TOKEN");
3417 }
3418 let tmp = tempfile::tempdir().unwrap();
3419 let repo = tmp.path().to_path_buf();
3420 write_serve_token(&repo, "file-token").unwrap();
3421
3422 assert_eq!(
3423 resolve_release_token_from_sources(
3424 &repo,
3425 OperatorTokenLookup::Absent,
3426 true,
3427 Some("flag-token".to_string()),
3428 ),
3429 Ok("flag-token".to_string())
3430 );
3431 }
3432
3433 #[test]
3434 fn release_reads_token_file_but_env_overrides() {
3435 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3436 unsafe {
3438 std::env::set_var("KRANZ_TOKEN", "env-token");
3439 }
3440 let tmp = tempfile::tempdir().unwrap();
3441 let repo = tmp.path().to_path_buf();
3442 write_serve_token(&repo, "file-token").unwrap();
3443
3444 let result =
3445 resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None);
3446 unsafe {
3447 std::env::remove_var("KRANZ_TOKEN");
3448 }
3449 assert_eq!(result, Ok("env-token".to_string()));
3450 }
3451
3452 #[test]
3455 fn release_reads_token_file_none_present_yields_none() {
3456 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3457 unsafe {
3459 std::env::remove_var("KRANZ_TOKEN");
3460 }
3461 let tmp = tempfile::tempdir().unwrap();
3462 let repo = tmp.path().to_path_buf();
3463
3464 assert_eq!(
3465 resolve_release_token_from_sources(&repo, OperatorTokenLookup::Absent, true, None),
3466 Err(ReleaseTokenError::Absent)
3467 );
3468 }
3469
3470 #[test]
3471 fn release_prefers_operator_process_token_over_repo_compatibility_token() {
3472 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3473 unsafe {
3474 std::env::remove_var("KRANZ_TOKEN");
3475 }
3476 let tmp = tempfile::tempdir().unwrap();
3477 let repo = tmp.path().join("repo");
3478 write_serve_token(&repo, "repo-token").unwrap();
3479 let global = tmp.path().join("operator").join("config.json");
3480 let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3481 let operator = operator_serve_token_path(&global, address);
3482 write_token_file(&operator, "operator-token").unwrap();
3483 let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3484
3485 assert_eq!(
3486 resolve_release_token_from_sources(
3487 &repo,
3488 operator_token_for_url(&global, &url),
3489 true,
3490 None,
3491 ),
3492 Ok("operator-token".to_string())
3493 );
3494 }
3495
3496 #[test]
3497 fn ambiguous_operator_token_does_not_fall_through_to_repo_file() {
3498 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3499 unsafe {
3500 std::env::remove_var("KRANZ_TOKEN");
3501 }
3502 let tmp = tempfile::tempdir().unwrap();
3503 let repo = tmp.path().join("repo");
3504 write_serve_token(&repo, "repo-token").unwrap();
3505
3506 assert_eq!(
3507 resolve_release_token_from_sources(&repo, OperatorTokenLookup::Ambiguous, true, None,),
3508 Err(ReleaseTokenError::Ambiguous)
3509 );
3510 }
3511
3512 #[test]
3513 fn operator_token_discovery_is_ambiguous_for_dual_localhost_endpoint_files() {
3514 let tmp = tempfile::tempdir().unwrap();
3515 let global = tmp.path().join(".kranz").join("config.json");
3516 let v4 = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3517 let v6 = std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 4560));
3518 write_token_file(&operator_serve_token_path(&global, v4), "v4-token").unwrap();
3519 write_token_file(&operator_serve_token_path(&global, v6), "v6-token").unwrap();
3520 let url = reqwest::Url::parse("http://localhost:4560").unwrap();
3521
3522 assert_eq!(
3523 operator_token_for_url(&global, &url),
3524 OperatorTokenLookup::Ambiguous
3525 );
3526 }
3527
3528 #[test]
3529 fn operator_token_path_is_scoped_by_full_bound_endpoint() {
3530 let global = Path::new("/operator/.kranz/config.json");
3531 let a =
3532 operator_serve_token_path(global, std::net::SocketAddr::from(([127, 0, 0, 1], 4560)));
3533 let b =
3534 operator_serve_token_path(global, std::net::SocketAddr::from(([127, 0, 0, 2], 4560)));
3535 assert_ne!(a, b);
3536 assert_eq!(
3537 a,
3538 Path::new("/operator/.kranz/serve/v4-7f000001-4560.token")
3539 );
3540 }
3541
3542 #[test]
3543 fn operator_token_discovery_handles_ipv6_url_brackets() {
3544 let tmp = tempfile::tempdir().unwrap();
3545 let global = tmp.path().join(".kranz").join("config.json");
3546 let address = std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 4560));
3547 write_token_file(&operator_serve_token_path(&global, address), "ipv6-token").unwrap();
3548 let url = reqwest::Url::parse("http://[::1]:4560").unwrap();
3549
3550 assert_eq!(
3551 operator_token_for_url(&global, &url),
3552 OperatorTokenLookup::Found("ipv6-token".to_string())
3553 );
3554 }
3555
3556 #[test]
3557 fn automatic_token_discovery_refuses_remote_domain_urls() {
3558 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3559 unsafe {
3560 std::env::remove_var("KRANZ_TOKEN");
3561 }
3562 let tmp = tempfile::tempdir().unwrap();
3563 let repo = tmp.path().join("repo");
3564 write_serve_token(&repo, "repo-token").unwrap();
3565
3566 assert_eq!(
3567 resolve_release_token(&repo, "https://example.com:4560", None),
3568 Err(ReleaseTokenError::Absent)
3569 );
3570 }
3571
3572 #[test]
3573 fn operator_token_discovery_refuses_non_loopback_ip_literals() {
3574 let tmp = tempfile::tempdir().unwrap();
3575 let global = tmp.path().join(".kranz").join("config.json");
3576 let address = std::net::SocketAddr::from(([203, 0, 113, 10], 4560));
3577 write_token_file(&operator_serve_token_path(&global, address), "remote-token").unwrap();
3578 let url = reqwest::Url::parse("http://203.0.113.10:4560").unwrap();
3579
3580 assert_eq!(
3581 operator_token_for_url(&global, &url),
3582 OperatorTokenLookup::Absent
3583 );
3584 }
3585
3586 #[test]
3587 fn operator_token_discovery_prefers_exact_loopback_over_stale_unspecified() {
3588 let tmp = tempfile::tempdir().unwrap();
3589 let global = tmp.path().join(".kranz").join("config.json");
3590 let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3591 let unspecified = std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 4560));
3592 write_token_file(&operator_serve_token_path(&global, loopback), "live-token").unwrap();
3593 write_token_file(
3594 &operator_serve_token_path(&global, unspecified),
3595 "stale-token",
3596 )
3597 .unwrap();
3598 let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3599
3600 assert_eq!(
3601 operator_token_for_url(&global, &url),
3602 OperatorTokenLookup::Found("live-token".to_string())
3603 );
3604 }
3605
3606 #[test]
3607 fn operator_token_discovery_falls_back_to_legacy_port_token() {
3608 let tmp = tempfile::tempdir().unwrap();
3609 let global = tmp.path().join(".kranz").join("config.json");
3610 write_token_file(
3611 &legacy_operator_serve_token_path(&global, 4560),
3612 "legacy-token",
3613 )
3614 .unwrap();
3615 let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3616
3617 assert_eq!(
3618 operator_token_for_url(&global, &url),
3619 OperatorTokenLookup::Legacy("legacy-token".to_string())
3620 );
3621 }
3622
3623 #[test]
3624 fn stale_legacy_operator_token_does_not_mask_live_repo_token() {
3625 let _guard = KRANZ_TOKEN_ENV_LOCK.lock().unwrap();
3626 unsafe {
3627 std::env::remove_var("KRANZ_TOKEN");
3628 }
3629 let tmp = tempfile::tempdir().unwrap();
3630 let repo = tmp.path().join("repo");
3631 write_serve_token(&repo, "live-repo-token").unwrap();
3632 let global = tmp.path().join(".kranz").join("config.json");
3633 write_token_file(
3634 &legacy_operator_serve_token_path(&global, 4560),
3635 "stale-legacy-token",
3636 )
3637 .unwrap();
3638 let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3639 let operator = operator_token_for_url(&global, &url);
3640
3641 assert_eq!(
3642 resolve_release_token_from_sources(&repo, operator, true, None),
3643 Ok("live-repo-token".to_string())
3644 );
3645 }
3646
3647 #[test]
3648 fn operator_token_discovery_prefers_endpoint_file_over_legacy_port_token() {
3649 let tmp = tempfile::tempdir().unwrap();
3650 let global = tmp.path().join(".kranz").join("config.json");
3651 let loopback = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 4560));
3652 write_token_file(
3653 &operator_serve_token_path(&global, loopback),
3654 "endpoint-token",
3655 )
3656 .unwrap();
3657 write_token_file(
3658 &legacy_operator_serve_token_path(&global, 4560),
3659 "legacy-token",
3660 )
3661 .unwrap();
3662 let url = reqwest::Url::parse("http://127.0.0.1:4560").unwrap();
3663
3664 assert_eq!(
3665 operator_token_for_url(&global, &url),
3666 OperatorTokenLookup::Found("endpoint-token".to_string())
3667 );
3668 }
3669
3670 #[test]
3671 fn release_repo_id_from_summaries_refuses_duplicate_root() {
3672 let tmp = tempfile::tempdir().unwrap();
3673 let repo = tmp.path().join("repo");
3674 std::fs::create_dir_all(&repo).unwrap();
3675 let root = repo.to_string_lossy().into_owned();
3676 let summaries = vec![
3677 kranz_server::RepoSummary {
3678 id: "alpha".to_string(),
3679 root: root.clone(),
3680 display_name: "alpha".to_string(),
3681 group: None,
3682 pinned: false,
3683 is_default: true,
3684 status: "healthy".to_string(),
3685 error: None,
3686 activity: kranz_server::RepoActivity::default(),
3687 },
3688 kranz_server::RepoSummary {
3689 id: "beta".to_string(),
3690 root,
3691 display_name: "beta".to_string(),
3692 group: None,
3693 pinned: false,
3694 is_default: false,
3695 status: "healthy".to_string(),
3696 error: None,
3697 activity: kranz_server::RepoActivity::default(),
3698 },
3699 ];
3700 let error = release_repo_id_from_summaries(&repo, &summaries).unwrap_err();
3701 assert!(error.to_string().contains("matches multiple"));
3702 }
3703
3704 #[test]
3705 fn slack_operator_catalog_is_not_rejected_by_legacy_context_helper() {
3706 fn init_git(root: &Path) {
3707 std::fs::create_dir_all(root).unwrap();
3708 let status = std::process::Command::new("git")
3709 .args(["init", "-q"])
3710 .arg(root)
3711 .status()
3712 .unwrap();
3713 assert!(status.success());
3714 }
3715
3716 let tmp = tempfile::tempdir().unwrap();
3717 let a = tmp.path().join("a");
3718 init_git(&a);
3719 let multi = kranz_server::MultiRepoHost::from_config(kranz_server::HostConfig {
3720 default_repo: Some("a".to_string()),
3721 max_concurrent_repos: 1,
3722 repos: vec![kranz_server::RepoConfig {
3723 id: "a".to_string(),
3724 root: a,
3725 display_name: None,
3726 group: None,
3727 pinned: false,
3728 slack: kranz_server::RepoSlackConfig::default(),
3729 }],
3730 })
3731 .unwrap();
3732 let context = single_repo_slack_context(&multi).unwrap();
3733 assert_eq!(context.id(), "a");
3734 }
3735
3736 #[test]
3737 fn release_endpoint_is_repo_scoped_when_catalog_id_is_known() {
3738 assert_eq!(
3739 release_endpoint("http://127.0.0.1:4560/", "same-id", Some("repo-b")),
3740 "http://127.0.0.1:4560/api/repos/repo-b/missions/same-id/release"
3741 );
3742 }
3743
3744 #[test]
3745 fn release_repo_id_from_summaries_matches_live_catalog_root() {
3746 let tmp = tempfile::tempdir().unwrap();
3747 let repo = tmp.path().join("repo");
3748 std::fs::create_dir_all(&repo).unwrap();
3749 let summaries = vec![kranz_server::RepoSummary {
3750 id: "alpha".to_string(),
3751 root: repo.to_string_lossy().into_owned(),
3752 display_name: "alpha".to_string(),
3753 group: None,
3754 pinned: false,
3755 is_default: true,
3756 status: "healthy".to_string(),
3757 error: None,
3758 activity: kranz_server::RepoActivity::default(),
3759 }];
3760 assert_eq!(
3761 release_repo_id_from_summaries(&repo, &summaries)
3762 .unwrap()
3763 .as_deref(),
3764 Some("alpha")
3765 );
3766 }
3767
3768 #[test]
3769 fn release_repo_id_from_summaries_refuses_unmatched_root() {
3770 let tmp = tempfile::tempdir().unwrap();
3771 let repo = tmp.path().join("local");
3772 let other = tmp.path().join("other");
3773 std::fs::create_dir_all(&repo).unwrap();
3774 std::fs::create_dir_all(&other).unwrap();
3775 let summaries = vec![kranz_server::RepoSummary {
3776 id: "other".to_string(),
3777 root: other.to_string_lossy().into_owned(),
3778 display_name: "other".to_string(),
3779 group: None,
3780 pinned: false,
3781 is_default: false,
3782 status: "healthy".to_string(),
3783 error: None,
3784 activity: kranz_server::RepoActivity::default(),
3785 }];
3786 let error = release_repo_id_from_summaries(&repo, &summaries).unwrap_err();
3787 assert!(error
3788 .to_string()
3789 .contains("not present in the live serve catalog"));
3790 }
3791
3792 #[test]
3793 fn release_repo_id_from_summaries_refuses_empty_catalog() {
3794 let tmp = tempfile::tempdir().unwrap();
3795 let error = release_repo_id_from_summaries(tmp.path(), &[]).unwrap_err();
3796 assert!(error.to_string().contains("empty repository catalog"));
3797 }
3798
3799 #[tokio::test]
3800 async fn live_release_repo_lookup_authenticates_protected_catalog() {
3801 let tmp = tempfile::tempdir().unwrap();
3802 let repo = tmp.path().join("repo");
3803 std::fs::create_dir_all(&repo).unwrap();
3804 let status = std::process::Command::new("git")
3805 .args(["init", "-q"])
3806 .arg(&repo)
3807 .status()
3808 .unwrap();
3809 assert!(status.success());
3810
3811 let catalog = Arc::new(kranz_server::MultiRepoHost::single(repo.clone()).unwrap());
3812 let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
3813 .await
3814 .unwrap();
3815 let address = listener.local_addr().unwrap();
3816 let app = kranz_server::router_with_multi_repo_host_and_addr(
3818 catalog,
3819 None,
3820 kranz_server::MutationAuthority::new("catalog-token").unwrap(),
3821 Some(address),
3822 true,
3823 false,
3824 );
3825 let server = tokio::spawn(async move {
3826 axum::serve(listener, app).await.unwrap();
3827 });
3828 let client = release_http_client().unwrap();
3829 let url = format!("http://{address}");
3830
3831 let repo_id = live_release_repo_id(&repo, &url, &client, "catalog-token")
3832 .await
3833 .unwrap();
3834
3835 server.abort();
3836 let _ = server.await;
3837 assert_eq!(repo_id.as_deref(), Some("repo"));
3838 }
3839
3840 #[tokio::test]
3841 async fn live_release_repo_lookup_retries_with_token_when_read_gated() {
3842 let tmp = tempfile::tempdir().unwrap();
3843 let repo = tmp.path().join("repo");
3844 std::fs::create_dir_all(&repo).unwrap();
3845 let status = std::process::Command::new("git")
3846 .args(["init", "-q"])
3847 .arg(&repo)
3848 .status()
3849 .unwrap();
3850 assert!(status.success());
3851
3852 let catalog = Arc::new(kranz_server::MultiRepoHost::single(repo.clone()).unwrap());
3853 let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
3854 .await
3855 .unwrap();
3856 let address = listener.local_addr().unwrap();
3857 let app = kranz_server::router_with_multi_repo_host_and_addr(
3862 catalog,
3863 None,
3864 kranz_server::MutationAuthority::new("catalog-token").unwrap(),
3865 Some(address),
3866 false,
3867 true,
3868 );
3869 let server = tokio::spawn(async move {
3870 axum::serve(listener, app).await.unwrap();
3871 });
3872 let client = release_http_client().unwrap();
3873 let url = format!("http://{address}");
3874
3875 let repo_id = live_release_repo_id(&repo, &url, &client, "catalog-token")
3876 .await
3877 .unwrap();
3878
3879 server.abort();
3880 let _ = server.await;
3881 assert_eq!(repo_id.as_deref(), Some("repo"));
3882 }
3883
3884 #[test]
3885 fn empty_token_files_are_ignored() {
3886 let tmp = tempfile::tempdir().unwrap();
3887 let path = tmp.path().join("serve.token");
3888 std::fs::write(&path, "").unwrap();
3889 assert_eq!(read_token_file(&path), None);
3890 }
3891
3892 #[cfg(unix)]
3893 #[test]
3894 fn serve_token_file_is_written_with_owner_only_permissions() {
3895 use std::os::unix::fs::PermissionsExt;
3896 let tmp = tempfile::tempdir().unwrap();
3897 let repo = tmp.path().to_path_buf();
3898 let path = write_serve_token(&repo, "secret").unwrap();
3899 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3900 assert_eq!(mode & 0o777, 0o600);
3901 }
3902
3903 #[cfg(unix)]
3904 #[test]
3905 fn serve_read_token_file_is_written_with_owner_only_permissions() {
3906 use std::os::unix::fs::PermissionsExt;
3907 let tmp = tempfile::tempdir().unwrap();
3908 let repo = tmp.path().to_path_buf();
3909 let path = write_serve_read_token(&repo, "read-secret").unwrap();
3910 assert!(path.ends_with(".kranz/serve.read.token"));
3911 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3912 assert_eq!(mode & 0o777, 0o600);
3913 }
3914
3915 #[test]
3916 fn operator_read_token_path_sits_beside_the_operator_token() {
3917 let global = Path::new("/home/op/.kranz/config.json");
3918 let address = std::net::SocketAddr::from(([127, 0, 0, 1], 4560));
3919 let mutation = operator_serve_token_path(global, address);
3920 let read = operator_serve_read_token_path(global, address);
3921 assert_eq!(read, mutation.with_extension("read.token"));
3922 assert!(read.to_string_lossy().ends_with(".read.token"));
3923 }
3924
3925 #[cfg(unix)]
3926 #[test]
3927 fn existing_token_permissions_are_hardened_before_replacement() {
3928 use std::os::unix::fs::PermissionsExt;
3929 let tmp = tempfile::tempdir().unwrap();
3930 let path = tmp.path().join("serve.token");
3931 std::fs::write(&path, "old-token").unwrap();
3932 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3933
3934 write_token_file(&path, "new-token").unwrap();
3935
3936 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new-token");
3937 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3938 assert_eq!(mode & 0o777, 0o600);
3939 }
3940
3941 #[tokio::test]
3942 async fn serve_token_file_is_removed_after_graceful_shutdown() {
3943 let tmp = tempfile::tempdir().unwrap();
3944 let repo = tmp.path().to_path_buf();
3945 let path = repo.join(".kranz").join("serve.token");
3946 let read_path = repo.join(".kranz").join("serve.read.token");
3947
3948 let host = std::sync::Arc::new(kranz_server::MissionHost::new(repo.clone()));
3949 let listener =
3950 kranz_server::bind_listener(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 0)
3951 .await
3952 .unwrap();
3953 serve_with_token_cleanup(
3954 &repo,
3955 host,
3956 listener,
3957 None,
3958 "tok".to_string(),
3959 "read-tok".to_string(),
3960 std::future::ready(()),
3961 )
3962 .await
3963 .unwrap();
3964
3965 assert!(!path.exists());
3966 assert!(!read_path.exists());
3967 }
3968
3969 fn dashboard_at(path: PathBuf) -> PathBuf {
3970 fs::create_dir_all(&path).unwrap();
3971 fs::write(path.join("index.html"), "<!doctype html>").unwrap();
3972 path
3973 }
3974
3975 fn inputs() -> DashboardResolutionInputs {
3976 DashboardResolutionInputs {
3977 embedded_available: false,
3978 ..Default::default()
3979 }
3980 }
3981
3982 #[test]
3983 fn dashboard_resolution_honors_explicit_path_verbatim() {
3984 let tmp = tempfile::tempdir().unwrap();
3985 let repo = tmp.path().join("repo");
3986 let explicit = tmp.path().join("missing-dashboard");
3987
3988 assert_eq!(
3989 resolve_dashboard_assets_from(&repo, Some(explicit.clone()), &inputs()),
3990 Some(DashboardAssets::Dir(explicit))
3991 );
3992 }
3993
3994 #[test]
3995 fn dashboard_resolution_env_precedes_repo_and_invalid_env_is_skipped() {
3996 let tmp = tempfile::tempdir().unwrap();
3997 let repo = tmp.path().join("repo");
3998 let repo_dist = dashboard_at(repo.join("apps").join("dashboard").join("dist"));
3999 let env_dist = dashboard_at(tmp.path().join("env-dist"));
4000
4001 let mut with_env = inputs();
4002 with_env.env_dist = Some(env_dist.clone());
4003 assert_eq!(
4004 resolve_dashboard_assets_from(&repo, None, &with_env),
4005 Some(DashboardAssets::Dir(env_dist))
4006 );
4007
4008 let mut with_invalid_env = inputs();
4009 with_invalid_env.env_dist = Some(tmp.path().join("missing-env-dist"));
4010 assert_eq!(
4011 resolve_dashboard_assets_from(&repo, None, &with_invalid_env),
4012 Some(DashboardAssets::Dir(repo_dist))
4013 );
4014 }
4015
4016 #[test]
4017 fn dashboard_resolution_finds_installed_asset_dirs() {
4018 let tmp = tempfile::tempdir().unwrap();
4019 let repo = tmp.path().join("repo");
4020 let exe = tmp.path().join("prefix").join("bin").join("kranz");
4021 let installed = dashboard_at(
4022 tmp.path()
4023 .join("prefix")
4024 .join("share")
4025 .join("kranz")
4026 .join("dashboard")
4027 .join("dist"),
4028 );
4029
4030 let mut inputs = inputs();
4031 inputs.exe = Some(exe);
4032 assert_eq!(
4033 resolve_dashboard_assets_from(&repo, None, &inputs),
4034 Some(DashboardAssets::Dir(installed))
4035 );
4036 }
4037
4038 #[test]
4039 fn dashboard_resolution_finds_checkout_used_to_build_installed_binary() {
4040 let tmp = tempfile::tempdir().unwrap();
4041 let repo = tmp.path().join("mission-repo");
4042 let checkout = tmp.path().join("kranz");
4043 let manifest_dir = checkout.join("crates").join("cli");
4044 let checkout_dist = dashboard_at(checkout.join("apps").join("dashboard").join("dist"));
4045
4046 let mut inputs = inputs();
4047 inputs.manifest_dir = Some(manifest_dir);
4048 inputs.exe = Some(tmp.path().join("cargo-home").join("bin").join("kranz"));
4049 assert_eq!(
4050 resolve_dashboard_assets_from(&repo, None, &inputs),
4051 Some(DashboardAssets::Dir(checkout_dist))
4052 );
4053 }
4054
4055 #[test]
4056 fn dashboard_resolution_falls_back_to_embedded_assets() {
4057 let tmp = tempfile::tempdir().unwrap();
4058 let repo = tmp.path().join("repo");
4059 let mut inputs = inputs();
4060 inputs.embedded_available = true;
4061
4062 assert_eq!(
4063 resolve_dashboard_assets_from(&repo, None, &inputs),
4064 Some(DashboardAssets::Embedded)
4065 );
4066 }
4067
4068 #[test]
4069 fn embedded_dashboard_bundle_contains_index() {
4070 assert!(
4071 crate::embedded_dashboard::EMBEDDED_DASHBOARD
4072 .iter()
4073 .any(|file| file.path == "index.html"),
4074 "embedded dashboard source: {}",
4075 crate::embedded_dashboard::EMBEDDED_DASHBOARD_SOURCE
4076 );
4077 }
4078
4079 #[test]
4080 fn serve_refuses_non_loopback_without_insecure_lan() {
4081 let bind: std::net::IpAddr = "0.0.0.0".parse().unwrap();
4082 let err = refuse_non_loopback_without_insecure_lan(bind, false).unwrap_err();
4083 let msg = err.to_string();
4084 assert!(
4085 msg.contains("refusing to bind") && msg.contains("--insecure-lan"),
4086 "unexpected error: {msg}"
4087 );
4088 }
4089
4090 #[test]
4091 fn serve_allows_non_loopback_with_insecure_lan() {
4092 let bind: std::net::IpAddr = "0.0.0.0".parse().unwrap();
4093 refuse_non_loopback_without_insecure_lan(bind, true).unwrap();
4094 }
4095
4096 #[test]
4097 fn serve_allows_loopback_without_insecure_lan() {
4098 let bind: std::net::IpAddr = "127.0.0.1".parse().unwrap();
4099 refuse_non_loopback_without_insecure_lan(bind, false).unwrap();
4100 }
4101
4102 #[test]
4103 fn read_auth_on_loopback_requires_read_token() {
4104 assert!(effective_require_read_token(true, true));
4105 }
4106
4107 #[test]
4108 fn read_auth_off_loopback_bind_does_not_require_read_token() {
4109 assert!(!effective_require_read_token(true, false));
4110 }
4111
4112 #[test]
4113 fn read_auth_non_loopback_always_requires_read_token() {
4114 assert!(effective_require_read_token(false, true));
4115 assert!(effective_require_read_token(false, false));
4116 }
4117
4118 fn reconcile_turn(reply: &str) -> Vec<kranz_engine::backend::AgentEvent> {
4123 vec![
4124 kranz_engine::backend_mock::mock_text(reply),
4125 kranz_engine::backend_mock::mock_result_text(reply),
4126 ]
4127 }
4128
4129 fn reconcile_worker_pass() -> kranz_engine::backend_mock::MockScript {
4130 kranz_engine::backend_mock::MockScript::single_shot_json(&serde_json::json!({
4131 "result": "pass",
4132 "summary": "implemented and tested",
4133 "filesTouched": ["delivered.txt"],
4134 "testsAdded": [],
4135 "testEvidence": "all green",
4136 "commits": []
4137 }))
4138 .writes_file("delivered.txt", "delivered by the mock worker\n")
4139 }
4140
4141 fn reconcile_plan_json() -> serde_json::Value {
4142 serde_json::json!({
4143 "goal": "ship the demo",
4144 "validationContract": [],
4145 "milestones": [{
4146 "title": "M1",
4147 "features": [{
4148 "title": "F1",
4149 "spec": "build the thing",
4150 "validationCriteria": ["it works"]
4151 }]
4152 }]
4153 })
4154 }
4155
4156 #[tokio::test]
4165 async fn reconcile_on_terminal_after_cli_run_marks_ticket_done() {
4166 let tmp = tempfile::tempdir().unwrap();
4167 let repo = tmp.path().to_path_buf();
4168 let status = std::process::Command::new("git")
4169 .args(["init", "-b", "main"])
4170 .current_dir(&repo)
4171 .status()
4172 .unwrap();
4173 assert!(status.success());
4174 std::process::Command::new("git")
4175 .args(["config", "user.name", "test"])
4176 .current_dir(&repo)
4177 .status()
4178 .unwrap();
4179 std::process::Command::new("git")
4180 .args(["config", "user.email", "test@example.com"])
4181 .current_dir(&repo)
4182 .status()
4183 .unwrap();
4184 std::fs::write(repo.join("README.md"), "seed\n").unwrap();
4185 std::process::Command::new("git")
4186 .args(["add", "-A"])
4187 .current_dir(&repo)
4188 .status()
4189 .unwrap();
4190 std::process::Command::new("git")
4191 .args(["commit", "-m", "seed"])
4192 .current_dir(&repo)
4193 .status()
4194 .unwrap();
4195 let repo = std::fs::canonicalize(&repo).unwrap();
4196
4197 let judgement = serde_json::json!({
4198 "decision": "complete",
4199 "guidance": "",
4200 "summary": "worker did the job"
4201 });
4202 let orch_setup = kranz_engine::backend_mock::MockScript::streaming(vec![
4206 kranz_engine::backend_mock::mock_init("orch-session"),
4207 kranz_engine::backend_mock::mock_result_text("seed-hi"),
4208 ])
4209 .responding(vec![
4210 reconcile_turn("let's scope the demo"),
4211 reconcile_turn(&reconcile_plan_json().to_string()),
4212 ]);
4213 let orch_run = kranz_engine::backend_mock::MockScript::streaming(vec![
4214 kranz_engine::backend_mock::mock_init("orch-session-2"),
4215 kranz_engine::backend_mock::mock_result_text("ack"),
4216 ])
4217 .responding(vec![
4218 reconcile_turn("ack"),
4219 reconcile_turn(
4220 &serde_json::json!({"action": "commit-as-is", "note": "worker delivered files"})
4221 .to_string(),
4222 ),
4223 reconcile_turn(&judgement.to_string()),
4224 reconcile_turn("NONE"),
4225 reconcile_turn("NONE"),
4229 reconcile_turn("NONE"),
4230 reconcile_turn("NONE"),
4231 ]);
4232 let backend: Arc<dyn AgentBackend> =
4233 Arc::new(kranz_engine::backend_mock::MockBackend::with_scripts(vec![
4234 orch_setup,
4235 kranz_engine::backend_mock::MockScript::single_shot("ok"),
4240 reconcile_worker_pass(),
4243 orch_run,
4244 ]));
4245
4246 let cfg = MissionConfig {
4247 skip_scrutiny: true,
4248 skip_functional: true,
4249 ..Default::default()
4250 };
4251 let mut engine =
4252 MissionEngine::create(Arc::clone(&backend), repo.clone(), "ship the demo", cfg)
4253 .unwrap();
4254 let mission_id = engine.mission_id().to_string();
4255 engine.planning_turn("ship the demo").await.unwrap();
4256 let request = engine.request_plan().await.unwrap();
4257 let plan = match request {
4258 PlanRequest::Ready(plan) => plan,
4259 PlanRequest::NotReady(text) => panic!("expected a ready plan, got: {text}"),
4260 PlanRequest::WrongPlan { reason } => {
4261 panic!("expected a ready plan, got a wrong-plan escalation: {reason}")
4262 }
4263 };
4264 engine.approve_plan(plan).unwrap();
4265 drop(engine);
4266
4267 kranz_engine::ticket::Ticket::record_mission(&repo, "my-ticket", &mission_id).unwrap();
4270 kranz_engine::ticket::Ticket::write_state(
4271 &repo,
4272 "my-ticket",
4273 kranz_engine::ticket::TicketState::Failed,
4274 None,
4275 )
4276 .unwrap();
4277 assert_eq!(
4278 kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
4279 kranz_engine::ticket::TicketState::Failed
4280 );
4281
4282 let exit_code =
4283 run_mission_loop_with_backend(repo.clone(), mission_id, LockForce::No, false, backend)
4284 .await
4285 .unwrap();
4286 assert_eq!(exit_code, 0);
4287
4288 assert_eq!(
4289 kranz_engine::ticket::Ticket::read_state(&repo, "my-ticket"),
4290 kranz_engine::ticket::TicketState::Done,
4291 "run_mission_loop must reconcile the linked ticket to Done on Complete"
4292 );
4293 }
4294}