1use std::collections::{BTreeMap, BTreeSet};
18use std::fs;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use std::time::Duration;
23
24use anyhow::{bail, Context, Result};
25
26const REQUIRED_BINS: &[&str] = &["ljos", "ljos-mcp", "ljos-policyd"];
27const OPTIONAL_BINS: &[&str] = &[
28 "packsetd",
29 "packset",
30 "packset-mcp",
31 "vissue",
32 "vissue-mcp",
33 "deedar",
34 "claimdag",
35 "ljos-consensus",
36];
37const INSTALL_URL: &str = "https://github.com/leidarljos/ljos/releases/latest/download/install.sh";
38const CLOUD_GUARD_RULES_FILE: &str = "cloud-guard-rules.json";
39const CURSOR_PUSH: &str = "cloud-cursor-push";
42const GH_PR_CREATE: &str = "cloud-gh-pr-create";
45const GH_PR_MERGE: &str = "cloud-gh-pr-merge";
47const LEGACY_CURSOR_PUSH: &str =
49 "re:git push(?:\\s+\\S+)*\\s(?:\\S*:)?(?:refs/heads/)?cursor/\\S+(?:\\s|$)";
50const LEGACY_GH_PR_CREATE: &str = "gh* pr create*";
53const LEGACY_GH_PR_MERGE: &str = "gh* pr merge*";
54const ENCODER_ARCHIVES: &[&str] = &["onnxruntime", "packset-embed"];
57const ENCODER_LIB: &str = "lib/onnxruntime";
59const ENCODER_SONAME: &str = "libonnxruntime.so.1";
60
61pub const CLOUD_AGE_SECRET: &str = "LJOS_CLOUD_AGE_SECRET";
65
66const CLOUD_AGE_MAX: usize = 8192;
69
70pub fn up(force: bool) -> Result<String> {
80 up_at(force, false)
81}
82
83pub fn up_at(force: bool, repoint: bool) -> Result<String> {
92 let home = crate::home().context("cloud up: HOME is unset")?;
93 let bin_dir = bin_dir(&home);
94 let mut lines = Vec::new();
95 lines.push(ensure_binaries(&bin_dir, force)?);
96 lines.push(ensure_encoder(&bin_dir, force)?);
97 let server = bin_dir.join("ljos-mcp");
98 let plan = if server.is_file() {
103 crate::server_command_plan(&server, "cloud up")
104 } else {
105 crate::ServerCommandPlan {
106 command: crate::MCP_SERVER_COMMAND.to_string(),
107 note: None,
108 }
109 };
110 lines.push(ensure_process_path(&bin_dir));
111
112 if agent_id().is_some() && seat_matches_file() {
113 crate::with_env_mutation(|| std::env::remove_var("LJOS_SEAT"));
116 }
117 let seat = default_seat();
118 lines.push(format!("seat: {seat}"));
119 let key = prepare_seat_key(&seat)?;
120 let public = crate::ballot_sig::key_from_file(&key)?.verifying_key();
121 lines.push(format!(
122 "seat key: {}, public {}",
123 key.display(),
124 hex::encode(public.to_bytes())
125 ));
126
127 let remote = nonempty_var("LJOS_TRACKER_REMOTE");
128 let tracker = remote.as_ref().map(|_| tracker_dir(&home));
129 lines.push(ensure_profile(&home, &bin_dir)?);
130 lines.push(write_mcp(
131 &home,
132 &server,
133 &plan,
134 &seat,
135 &key,
136 tracker.as_deref(),
137 remote.as_deref(),
138 )?);
139
140 let mut pairs = vec![
141 ("LJOS_SEAT", seat.clone()),
142 ("VISSUE_AGENT", seat.clone()),
143 ("LJOS_SEAT_KEY", key.display().to_string()),
144 (
147 crate::sync::WRITE_SCOPE_KEY,
148 crate::sync::CLOUD_SCOPE.to_string(),
149 ),
150 ];
151 if std::env::var("DEEDAR_HOST_SIGNING_KEY").ok().as_deref() != Some("off") {
152 pairs.push(("DEEDAR_HOST_SIGNING_KEY", key.display().to_string()));
153 }
154 if let (Some(remote), Some(dest)) = (remote.as_deref(), tracker.as_deref()) {
155 lines.push(ensure_tracker(remote, dest, &seat)?);
156 pairs.push(("LJOS_TRACKER_GIT", "push".into()));
157 pairs.push(("LJOS_TRACKER_REMOTE", remote.to_string()));
158 pairs.push(("VISSUE_ROOT", dest.display().to_string()));
159 pairs.push(("ISSUE_ROOT", dest.display().to_string()));
160 } else {
161 lines.push(
162 "tracker: LJOS_TRACKER_REMOTE is unset, so this machine has no shared tracker".into(),
163 );
164 }
165 write_env(&home.join(".config/ljos/env"), &pairs)?;
166 crate::with_env_mutation(|| {
167 for (k, v) in &pairs {
168 std::env::set_var(k, v);
169 }
170 });
171 lines.push(ensure_cloud_guard_rules()?);
172 lines.push(crate::cloud_hook::ensure(repoint)?);
176 lines.extend(ensure_wakes()?);
179 lines.push(ensure_cloud_age()?);
180 let reaped = reap_gone_owners(crate::lease_secs());
181 if !reaped.trim().is_empty() {
182 lines.push(reaped.trim_end().to_string());
183 }
184 Ok(lines.join("\n") + "\n")
185}
186
187#[must_use]
194pub fn default_seat() -> String {
195 let id = agent_id();
196 if let Some(seat) = nonempty_var("LJOS_SEAT") {
197 return with_run(&crate::seat_slug(&seat), id.as_deref());
198 }
199 if let Some(id) = id {
200 return crate::seat_slug(&format!("{}-{id}", harness_kind()));
201 }
202 crate::seat_slug(&format!("vm-{}", hostname()))
203}
204
205pub fn run_seat(base: &str, run: &str) -> Result<String> {
214 let base = crate::seat_slug(base);
215 let id = run_slug(run);
216 if let Some(id) = id.as_deref() {
217 if let Some(bare) = base.strip_suffix(id).and_then(|b| b.strip_suffix('-')) {
218 bail!(
219 "{base} already ends with run id {id}, and {bare} on that run boots as {base} \
220 too; name the seat as LJOS_SEAT holds it before `cloud up` appends the id"
221 );
222 }
223 }
224 Ok(with_run(&base, id.as_deref()))
225}
226
227fn with_run(base: &str, id: Option<&str>) -> String {
228 let Some(id) = id else {
229 return base.to_string();
230 };
231 if base == id || base.ends_with(&format!("-{id}")) {
232 return base.to_string();
233 }
234 crate::seat_slug(&format!("{base}-{id}"))
235}
236
237pub fn prepare_seat_key(seat: &str) -> Result<PathBuf> {
244 let path =
245 crate::ballot_sig::seat_key_file(seat).context("cloud up: no directory for a seat key")?;
246 crate::ballot_sig::load_or_create(&path)?;
247 Ok(path)
248}
249
250#[must_use]
252pub fn mcp_row() -> crate::Habitat {
253 match mcp_problem() {
254 McpProblem::None => crate::Habitat {
255 name: "mcp",
256 state: "no harness mcp config".into(),
257 ok: true,
258 },
259 McpProblem::Ready(state) => crate::Habitat {
260 name: "mcp",
261 state,
262 ok: true,
263 },
264 McpProblem::Warn(state) => crate::Habitat {
265 name: "mcp",
266 state: format!("warning: {state}"),
267 ok: true,
268 },
269 McpProblem::Missing(state) => crate::Habitat {
270 name: "mcp",
271 state,
272 ok: false,
273 },
274 }
275}
276
277#[must_use]
281pub fn guard_row() -> crate::Habitat {
282 let Some(path) = cloud_guard_rules_path() else {
283 return crate::Habitat {
284 name: "cloud guard",
285 state: "no config home for cloud guard rules".into(),
286 ok: false,
287 };
288 };
289 let have = crate::kept_rules(&path);
290 if current_rules(&have) {
291 let base = format!(
292 "{} denies cursor/* pushes and `gh pr create|merge`",
293 path.display()
294 );
295 return match crate::cloud_hook::hook_line() {
298 Ok(line) => crate::Habitat {
299 name: "cloud guard",
300 state: format!("{base}; {line}"),
301 ok: true,
302 },
303 Err(why) => crate::Habitat {
304 name: "cloud guard",
305 state: format!("{base}; {why}"),
306 ok: false,
307 },
308 };
309 }
310 let state = if have.is_empty() {
311 format!(
312 "missing in {}; run `ljos cloud up` to install the cloud seat refusals",
313 path.display()
314 )
315 } else if covers_seat(&have) {
316 format!("{} is stale; ljos doctor --fix", path.display())
317 } else {
318 format!(
319 "{} differs from the cloud seat refusals; run `ljos cloud up`",
320 path.display()
321 )
322 };
323 crate::Habitat {
324 name: "cloud guard",
325 state,
326 ok: false,
327 }
328}
329
330#[must_use]
333pub(crate) fn guard_row_is_warning(state: &str) -> bool {
334 state.contains("is stale;")
335 || state.contains("differs from the cloud seat refusals")
336 || state.contains("pre-push hook")
337}
338
339#[must_use]
343pub(crate) fn cloud_age_is_unset(name: &str, state: &str) -> bool {
344 name == "cloud age" && state.starts_with("unset;")
345}
346
347#[must_use]
349pub fn cloud_age_row() -> crate::Habitat {
350 let path = crate::sync::cloud_identity_path();
351 match inspect_cloud_age(&path) {
352 CloudAge::Good => crate::Habitat {
353 name: "cloud age",
354 state: format!("scope cloud identity at {} (0600)", path.display()),
355 ok: true,
356 },
357 CloudAge::BadMode(mode) => crate::Habitat {
358 name: "cloud age",
359 state: format!("{} is mode {mode:04o}; want 0600", path.display()),
360 ok: false,
361 },
362 CloudAge::NotIdentity => crate::Habitat {
363 name: "cloud age",
364 state: format!("{} is not an age identity", path.display()),
365 ok: false,
366 },
367 CloudAge::Symlink => crate::Habitat {
368 name: "cloud age",
369 state: format!("{} is a symlink; want a mode 0600 file", path.display()),
370 ok: false,
371 },
372 CloudAge::Unreadable => crate::Habitat {
373 name: "cloud age",
374 state: format!("{} cannot be read", path.display()),
375 ok: false,
376 },
377 CloudAge::Missing => missing_cloud_age(&path),
378 }
379}
380
381#[must_use]
384pub fn missing_mcp_line() -> Option<String> {
385 match mcp_problem() {
386 McpProblem::Missing(state) => Some(state),
387 McpProblem::None | McpProblem::Ready(_) | McpProblem::Warn(_) => None,
388 }
389}
390
391#[must_use]
396pub(crate) fn guard_line(line: &str, cwd: Option<&Path>) -> Option<crate::Rule> {
397 let path = cloud_guard_rules_path()?;
398 let gh = crate::cloud_guard::gh_acts(line, cwd);
399 let denies = crate::kept_rules(&path)
400 .into_iter()
401 .filter(|rule| rule.verdict == "deny");
402 for rule in denies {
403 match seat_part(&rule.pattern) {
404 Some(SeatPart::Push) => {
405 if let Some(why) = crate::cloud_guard::cursor_push(line, cwd) {
406 return Some(crate::Rule {
407 reason: format!("{} ({why})", rule.reason),
408 ..rule
409 });
410 }
411 }
412 Some(SeatPart::Open) if gh.open => return Some(rule),
413 Some(SeatPart::Merge) if gh.merge => return Some(rule),
414 Some(SeatPart::Open | SeatPart::Merge) => {}
415 None => {
416 if let Some(hit) = crate::verdict_for(std::slice::from_ref(&rule), line) {
417 return Some(hit.clone());
418 }
419 }
420 }
421 }
422 None
423}
424
425pub fn merge_org_cli(base: &Path, ours: &Path, theirs: &Path) -> Result<()> {
431 crate::tracker_push::merge_org_files(base, ours, theirs).map_err(|e| anyhow::anyhow!("{e}"))
432}
433
434#[must_use]
438pub fn reap_gone_owners(lease: u64) -> String {
439 crate::load_seat_env();
440 if std::env::var("LJOS_TRACKER_GIT").ok().as_deref() != Some("push") {
441 return String::new();
442 }
443 let Ok(layout) = vissue_core::Layout::resolve(None, None) else {
444 return String::new();
445 };
446 let Ok(issues) = vissue_core::store::load_all(&layout) else {
447 return String::new();
448 };
449 let now = std::time::SystemTime::now()
450 .duration_since(std::time::UNIX_EPOCH)
451 .map_or(0, |d| d.as_secs() as i64);
452 let mut out = String::new();
453 for (_project, heading) in issues {
454 if heading.state != "STARTED" {
455 continue;
456 }
457 let Some(who) = heading.claimed_by().map(str::to_string) else {
458 continue;
459 };
460 if holder_running(&who, &heading.id) {
461 continue;
462 }
463 let idle = match heading.claimed_at().and_then(crate::reap::org_ts) {
464 Some(taken) => taken.saturating_add(lease as i64) <= now,
465 None => true,
466 };
467 if !idle {
468 continue;
469 }
470 match release_started(&heading.id, &who) {
471 Ok(Some(line)) => {
472 out.push_str(&line);
473 out.push('\n');
474 out.push_str(&crate::persist_tracker(&heading.id, "release"));
475 }
476 Ok(None) => {}
477 Err(e) => out.push_str(&format!("could not release {}: {e}\n", heading.id)),
478 }
479 }
480 out
481}
482
483enum McpProblem {
484 None,
485 Ready(String),
486 Warn(String),
487 Missing(String),
488}
489
490struct McpFile {
491 path: PathBuf,
492 probe: McpProbe,
493 advice: String,
494 expand: Expand,
495 launch_path: Option<String>,
498 strict: bool,
501}
502
503#[derive(Clone, Copy)]
512enum Expand {
513 Literal,
514 Opencode,
515 Cursor,
516 Claude,
517 Shell,
518}
519
520enum McpProbe {
521 Json(String),
522 Text(String),
523}
524
525struct LaunchSpec {
526 command: String,
527 args: Vec<String>,
528 env_path: Option<String>,
529}
530
531enum LaunchCheck {
532 Ready,
533 Warn(String),
534 Missing(String),
535}
536
537fn mcp_problem() -> McpProblem {
538 let mut missing = Vec::new();
539 let mut warned = Vec::new();
540 let mut ready = Vec::new();
541 for file in mcp_files() {
542 if !file.path.is_file() {
543 continue;
544 }
545 let launch = match &file.probe {
546 McpProbe::Json(pointer) => match read_json_launch(&file.path, pointer) {
547 Ok(found) => found,
548 Err(e) if file.strict => {
549 missing.push(format!(
550 "ljos-mcp: {} is not readable ({e}), so the MCP client has no tools. Run `{}`.",
551 file.path.display(),
552 file.advice
553 ));
554 continue;
555 }
556 Err(_) => None,
557 },
558 McpProbe::Text(marker) => read_text_launch(&file.path, marker),
559 };
560 let Some(launch) = launch else {
561 continue;
562 };
563 match launch_check(&file, &launch) {
564 LaunchCheck::Ready => {
565 ready.push(format!("{} starts {}", file.path.display(), launch.command))
566 }
567 LaunchCheck::Warn(why) => warned.push(format!(
568 "ljos-mcp: {why} ({}). Run `{}`.",
569 file.path.display(),
570 file.advice
571 )),
572 LaunchCheck::Missing(why) => missing.push(format!(
573 "ljos-mcp: {why} ({}), so the MCP client has no tools. Run `{}`.",
574 file.path.display(),
575 file.advice
576 )),
577 }
578 }
579 if !missing.is_empty() {
580 return McpProblem::Missing(missing.join("; "));
581 }
582 if !warned.is_empty() {
583 return McpProblem::Warn(warned.join("; "));
584 }
585 if !ready.is_empty() {
586 return McpProblem::Ready(ready.join("; "));
587 }
588 McpProblem::None
589}
590
591fn mcp_files() -> Vec<McpFile> {
595 collect_mcp_files(true)
596}
597
598fn collect_mcp_files(include_user_harness: bool) -> Vec<McpFile> {
602 let mut out = Vec::new();
603 let mut seen = BTreeSet::new();
604 let mut push = |path: PathBuf,
605 probe: McpProbe,
606 strict: bool,
607 advice: String,
608 expand: Expand,
609 launch_path: Option<&str>| {
610 let kind = match &probe {
611 McpProbe::Json(pointer) => format!("json:{pointer}"),
612 McpProbe::Text(marker) => format!("text:{marker}"),
613 };
614 if seen.insert(format!("{kind}:{}", path.display())) {
615 out.push(McpFile {
616 path,
617 probe,
618 advice,
619 expand,
620 launch_path: launch_path.map(str::to_string),
621 strict,
622 });
623 }
624 };
625 if let Some(path) = std::env::var_os("LJOS_MCP_CONFIG").filter(|p| !p.is_empty()) {
626 push(
627 PathBuf::from(path),
628 McpProbe::Json("/mcpServers/ljos".into()),
629 true,
630 "ljos onboard --harness cursor".into(),
631 Expand::Shell,
632 None,
633 );
634 }
635 if let Ok(home) = crate::home() {
636 push(
637 home.join(".cursor/mcp.json"),
638 McpProbe::Json("/mcpServers/ljos".into()),
639 true,
640 cursor_mcp_advice(),
641 Expand::Cursor,
642 None,
643 );
644 push(
645 home.join(".claude.json"),
646 McpProbe::Json("/mcpServers/ljos".into()),
647 false,
648 "ljos onboard --harness claude".into(),
649 Expand::Claude,
650 None,
651 );
652 }
653 for harness in harness_shapes_for(include_user_harness) {
654 let advice = format!("ljos onboard --harness {}", harness.name);
655 let expand = match harness.name.as_str() {
656 "codex" => Expand::Literal,
657 "opencode" | "opencode-v2" => Expand::Opencode,
658 "cursor" => Expand::Cursor,
659 _ => Expand::Shell,
660 };
661 if let (Some(config), Some(pointer)) = (harness.json_config(), &harness.json_pointer) {
662 if !pointer.is_empty() {
663 push(
664 crate::expand(config),
665 McpProbe::Json(pointer.clone()),
666 false,
667 advice.clone(),
668 expand,
669 harness.launch_path(),
670 );
671 }
672 }
673 if let (Some(config), Some(marker)) = (&harness.config, &harness.marker) {
674 if !marker.is_empty() {
675 push(
676 crate::expand(config),
677 McpProbe::Text(marker.clone()),
678 false,
679 advice.clone(),
680 expand,
681 harness.launch_path(),
682 );
683 }
684 }
685 }
686 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|p| !p.is_empty()) {
687 push(
688 PathBuf::from(&dir).join(".claude.json"),
689 McpProbe::Json("/mcpServers/ljos".into()),
690 false,
691 "ljos onboard --harness claude".into(),
692 Expand::Claude,
693 None,
694 );
695 }
696 if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|p| !p.is_empty()) {
697 push(
698 PathBuf::from(&dir).join("config.toml"),
699 McpProbe::Text("[mcp_servers.ljos]".into()),
700 false,
701 "ljos onboard --harness codex".into(),
702 Expand::Literal,
703 None,
704 );
705 }
706 for dir in project_config_dirs() {
707 push(
708 dir.join(".mcp.json"),
709 McpProbe::Json("/mcpServers/ljos".into()),
710 false,
711 "project .mcp.json".into(),
712 Expand::Shell,
713 None,
714 );
715 push(
716 dir.join(".cursor/mcp.json"),
717 McpProbe::Json("/mcpServers/ljos".into()),
718 false,
719 "project .cursor/mcp.json".into(),
720 Expand::Cursor,
721 None,
722 );
723 }
724 out
725}
726
727fn project_config_dirs() -> Vec<PathBuf> {
731 let Ok(cwd) = std::env::current_dir() else {
732 return Vec::new();
733 };
734 if !cwd.is_dir() {
735 return Vec::new();
736 }
737 let mut dirs = vec![cwd.clone()];
738 let mut at = cwd.clone();
739 loop {
740 if at.join(".git").exists() {
741 if at != cwd {
742 dirs.push(at);
743 }
744 break;
745 }
746 if !at.pop() {
747 break;
748 }
749 }
750 dirs
751}
752
753fn harness_shapes_for(include_user_harness: bool) -> Vec<crate::Harness> {
754 let shipped: crate::Harnesses = toml::from_str(crate::HARNESSES_EXAMPLE).unwrap_or_default();
755 let mut shapes = if include_user_harness {
756 crate::harnesses_with_shipped(&crate::harnesses_path())
757 .map(|h| h.harness)
758 .unwrap_or_default()
759 } else {
760 Vec::new()
761 };
762 for harness in shipped.harness {
763 if harness.name.starts_with("runner-with-") || harness.shell {
764 continue;
765 }
766 if !shapes.iter().any(|have| have.name == harness.name) {
767 shapes.push(harness);
768 }
769 }
770 shapes.retain(|h| !h.name.starts_with("runner-with-") && !h.shell);
771 shapes
772}
773
774const MCP_CONFIG_CAP: u64 = 1024 * 1024;
777
778#[derive(Debug, Clone, PartialEq, Eq)]
780pub(crate) enum SeatMcpKeys {
781 Unknown,
783 Ambiguous,
785 Known(BTreeSet<String>),
787}
788
789pub(crate) fn seat_mcp_keys() -> SeatMcpKeys {
802 let mut keys = BTreeSet::new();
803 let mut saw = false;
804 let mut ambiguous = false;
805 for file in collect_mcp_files(false) {
806 if !seat_config_file(&file.path) {
807 continue;
808 }
809 match read_seat_keys(&file) {
810 KeyRead::Absent | KeyRead::Skip => {}
811 KeyRead::Ambiguous => ambiguous = true,
812 KeyRead::Keys(found) => {
813 saw = true;
814 keys.extend(found);
815 }
816 }
817 }
818 let plugin = plugin_seat_keys();
819 if !plugin.is_empty() {
820 saw = true;
821 keys.extend(plugin);
822 }
823 if saw {
824 SeatMcpKeys::Known(keys)
825 } else if ambiguous {
826 SeatMcpKeys::Ambiguous
827 } else {
828 SeatMcpKeys::Unknown
829 }
830}
831
832fn seat_config_file(path: &Path) -> bool {
836 let name = path.file_name().and_then(|n| n.to_str());
837 if name == Some(".mcp.json") {
838 return false;
839 }
840 if name == Some("mcp.json")
841 && path
842 .parent()
843 .and_then(|dir| dir.file_name())
844 .and_then(|n| n.to_str())
845 == Some(".cursor")
846 {
847 let home_cursor = crate::home().ok().map(|home| home.join(".cursor/mcp.json"));
848 if home_cursor.as_deref() != Some(path) {
849 return false;
850 }
851 }
852 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|p| !p.is_empty()) {
853 let from_env = PathBuf::from(&dir).join(".claude.json");
854 if path == from_env {
855 let home_claude = crate::home().ok().map(|home| home.join(".claude.json"));
856 if home_claude.as_deref() != Some(path) {
857 return false;
858 }
859 }
860 }
861 if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|p| !p.is_empty()) {
862 let from_env = PathBuf::from(&dir).join("config.toml");
863 if path == from_env {
864 let home_codex = crate::home()
865 .ok()
866 .map(|home| home.join(".codex/config.toml"));
867 if home_codex.as_deref() != Some(path) {
868 return false;
869 }
870 }
871 }
872 true
873}
874
875#[derive(Debug)]
876enum KeyRead {
877 Absent,
878 Skip,
879 Ambiguous,
880 Keys(BTreeSet<String>),
881}
882
883enum TextRead {
884 Absent,
885 Bad,
886 Text(String),
887}
888
889fn read_seat_keys(file: &McpFile) -> KeyRead {
890 let text = match read_config_text(&file.path) {
891 TextRead::Absent => return KeyRead::Absent,
892 TextRead::Bad => {
893 return if file.strict {
894 KeyRead::Ambiguous
895 } else {
896 KeyRead::Skip
897 };
898 }
899 TextRead::Text(text) => text,
900 };
901 if text.trim().is_empty() {
902 return KeyRead::Keys(BTreeSet::new());
903 }
904 match &file.probe {
905 McpProbe::Json(_) => match serde_json::from_str::<serde_json::Value>(&text) {
906 Ok(doc) => KeyRead::Keys(json_seat_keys(&doc)),
907 Err(_) => {
908 if file.strict {
909 KeyRead::Ambiguous
910 } else {
911 KeyRead::Skip
912 }
913 }
914 },
915 McpProbe::Text(marker) => {
916 if let Ok(doc) = toml::from_str::<toml::Value>(&text) {
917 return KeyRead::Keys(toml_seat_keys(&doc));
918 }
919 let mut keys = BTreeSet::new();
920 if let Some(key) = text_marker_key(&text, marker) {
921 remember_key(&mut keys, &key);
922 }
923 KeyRead::Keys(keys)
924 }
925 }
926}
927
928fn read_config_text(path: &Path) -> TextRead {
929 use std::io::Read;
930 use std::os::unix::fs::OpenOptionsExt;
931 if path.file_name().and_then(|name| name.to_str()) == Some(".claude.json") {
936 return read_claude_config(path);
937 }
938 let file = match std::fs::OpenOptions::new()
939 .read(true)
940 .custom_flags(libc::O_NONBLOCK)
941 .open(path)
942 {
943 Ok(file) => file,
944 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return TextRead::Absent,
945 Err(_) => return TextRead::Bad,
946 };
947 let Ok(meta) = file.metadata() else {
948 return TextRead::Bad;
949 };
950 if !meta.is_file() || meta.len() > MCP_CONFIG_CAP {
951 return TextRead::Bad;
952 }
953 let mut text = String::new();
954 match file.take(MCP_CONFIG_CAP).read_to_string(&mut text) {
955 Ok(_) => TextRead::Text(text),
956 Err(_) => TextRead::Bad,
957 }
958}
959
960fn read_claude_config(path: &Path) -> TextRead {
961 use std::io::BufReader;
962 use std::os::unix::fs::OpenOptionsExt;
963 let file = match std::fs::OpenOptions::new()
964 .read(true)
965 .custom_flags(libc::O_NONBLOCK)
966 .open(path)
967 {
968 Ok(file) => file,
969 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return TextRead::Absent,
970 Err(_) => return TextRead::Bad,
971 };
972 let Ok(meta) = file.metadata() else {
973 return TextRead::Bad;
974 };
975 if !meta.is_file() {
976 return TextRead::Bad;
977 }
978 if meta.len() == 0 {
979 return TextRead::Text(String::new());
980 }
981 let mut reader = BufReader::with_capacity(8192, file);
982 match claude_mcp_servers_value(&mut reader) {
983 ClaudeRead::Servers(body) => TextRead::Text(body),
984 ClaudeRead::NoServers => TextRead::Text("{}".into()),
985 ClaudeRead::Bad => TextRead::Bad,
986 }
987}
988
989enum ClaudeRead {
990 Servers(String),
991 NoServers,
992 Bad,
993}
994
995fn claude_mcp_servers_value(reader: &mut impl std::io::Read) -> ClaudeRead {
998 let mut inp = JsonIn {
999 reader,
1000 unread: None,
1001 };
1002 skip_ws(&mut inp);
1003 if inp.peek() != Some(b'{') {
1004 return ClaudeRead::Bad;
1005 }
1006 let _ = inp.bump();
1007 loop {
1008 skip_ws(&mut inp);
1009 match inp.peek() {
1010 None => return ClaudeRead::Bad,
1011 Some(b'}') => return ClaudeRead::NoServers,
1012 Some(b'"') => {}
1013 Some(_) => return ClaudeRead::Bad,
1014 }
1015 let Some(key) = read_json_string(&mut inp) else {
1016 return ClaudeRead::Bad;
1017 };
1018 skip_ws(&mut inp);
1019 if inp.bump() != Some(b':') {
1020 return ClaudeRead::Bad;
1021 }
1022 skip_ws(&mut inp);
1023 if key == "mcpServers" {
1024 let Some(raw) = read_json_value(&mut inp) else {
1025 return ClaudeRead::Bad;
1026 };
1027 let Ok(text) = String::from_utf8(raw) else {
1028 return ClaudeRead::Bad;
1029 };
1030 return ClaudeRead::Servers(format!("{{\"mcpServers\":{text}}}"));
1031 }
1032 if read_json_value(&mut inp).is_none() {
1033 return ClaudeRead::Bad;
1034 }
1035 skip_ws(&mut inp);
1036 match inp.peek() {
1037 Some(b',') => {
1038 let _ = inp.bump();
1039 }
1040 Some(b'}') => return ClaudeRead::NoServers,
1041 _ => return ClaudeRead::Bad,
1042 }
1043 }
1044}
1045
1046struct JsonIn<'a> {
1047 reader: &'a mut dyn std::io::Read,
1048 unread: Option<u8>,
1049}
1050
1051impl JsonIn<'_> {
1052 fn bump(&mut self) -> Option<u8> {
1053 if let Some(byte) = self.unread.take() {
1054 return Some(byte);
1055 }
1056 let mut buf = [0u8; 1];
1057 match self.reader.read(&mut buf) {
1058 Ok(1) => Some(buf[0]),
1059 _ => None,
1060 }
1061 }
1062
1063 fn peek(&mut self) -> Option<u8> {
1064 if let Some(byte) = self.unread {
1065 return Some(byte);
1066 }
1067 let byte = self.bump()?;
1068 self.unread = Some(byte);
1069 Some(byte)
1070 }
1071}
1072
1073fn skip_ws(inp: &mut JsonIn<'_>) {
1074 while inp.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
1075 let _ = inp.bump();
1076 }
1077}
1078
1079fn read_json_string(inp: &mut JsonIn<'_>) -> Option<String> {
1080 if inp.bump() != Some(b'"') {
1081 return None;
1082 }
1083 let mut out = Vec::new();
1084 loop {
1085 let byte = inp.bump()?;
1086 if byte == b'\\' {
1087 let esc = inp.bump()?;
1088 let decoded = match esc {
1089 b'"' | b'\\' | b'/' => esc,
1090 b'b' => 0x08,
1091 b'f' => 0x0c,
1092 b'n' => b'\n',
1093 b'r' => b'\r',
1094 b't' => b'\t',
1095 b'u' => {
1096 let mut hex = [0u8; 4];
1097 for slot in &mut hex {
1098 *slot = inp.bump()?;
1099 }
1100 let text = std::str::from_utf8(&hex).ok()?;
1101 let code = u32::from_str_radix(text, 16).ok()?;
1102 let ch = char::from_u32(code)?;
1103 let mut buf = [0u8; 4];
1104 out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1105 continue;
1106 }
1107 _ => return None,
1108 };
1109 out.push(decoded);
1110 continue;
1111 }
1112 if byte == b'"' {
1113 break;
1114 }
1115 out.push(byte);
1116 }
1117 String::from_utf8(out).ok()
1118}
1119
1120fn read_json_value(inp: &mut JsonIn<'_>) -> Option<Vec<u8>> {
1121 match inp.peek()? {
1122 b'{' => read_json_container(inp, b'{', b'}'),
1123 b'[' => read_json_container(inp, b'[', b']'),
1124 b'"' => read_json_string_raw(inp),
1125 b't' => read_json_lit(inp, b"true"),
1126 b'f' => read_json_lit(inp, b"false"),
1127 b'n' => read_json_lit(inp, b"null"),
1128 b'-' | b'0'..=b'9' => read_json_number(inp),
1129 _ => None,
1130 }
1131}
1132
1133fn read_json_container(inp: &mut JsonIn<'_>, open: u8, close: u8) -> Option<Vec<u8>> {
1134 let first = inp.bump()?;
1135 if first != open {
1136 return None;
1137 }
1138 let mut out = vec![first];
1139 let mut depth = 1u32;
1140 let mut in_str = false;
1141 let mut escaped = false;
1142 while depth > 0 {
1143 let byte = inp.bump()?;
1144 out.push(byte);
1145 if in_str {
1146 if escaped {
1147 escaped = false;
1148 continue;
1149 }
1150 if byte == b'\\' {
1151 escaped = true;
1152 continue;
1153 }
1154 if byte == b'"' {
1155 in_str = false;
1156 }
1157 continue;
1158 }
1159 if byte == b'"' {
1160 in_str = true;
1161 } else if byte == open {
1162 depth += 1;
1163 } else if byte == close {
1164 depth -= 1;
1165 }
1166 }
1167 Some(out)
1168}
1169
1170fn read_json_string_raw(inp: &mut JsonIn<'_>) -> Option<Vec<u8>> {
1171 if inp.bump() != Some(b'"') {
1172 return None;
1173 }
1174 let mut out = vec![b'"'];
1175 loop {
1176 let byte = inp.bump()?;
1177 out.push(byte);
1178 if byte == b'\\' {
1179 out.push(inp.bump()?);
1180 continue;
1181 }
1182 if byte == b'"' {
1183 break;
1184 }
1185 }
1186 Some(out)
1187}
1188
1189fn read_json_lit(inp: &mut JsonIn<'_>, lit: &[u8]) -> Option<Vec<u8>> {
1190 for expected in lit {
1191 if inp.bump()? != *expected {
1192 return None;
1193 }
1194 }
1195 Some(lit.to_vec())
1196}
1197
1198fn read_json_number(inp: &mut JsonIn<'_>) -> Option<Vec<u8>> {
1199 let mut out = Vec::new();
1200 while inp.peek().is_some_and(|byte| {
1201 byte.is_ascii_digit() || matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E')
1202 }) {
1203 out.push(inp.bump()?);
1204 }
1205 (!out.is_empty()).then_some(out)
1206}
1207
1208fn plugin_seat_keys() -> BTreeSet<String> {
1212 let mut out = BTreeSet::new();
1213 if let Ok(home) = crate::home() {
1216 plugin_keys_from(&home.join(".claude"), &mut out);
1217 }
1218 out
1219}
1220
1221fn plugin_keys_from(base: &Path, out: &mut BTreeSet<String>) {
1222 let enabled = enabled_plugins(&base.join("settings.json"));
1223 let Some(text) = read_capped(&base.join("plugins/installed_plugins.json")) else {
1224 return;
1225 };
1226 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&text) else {
1227 return;
1228 };
1229 let Some(plugins) = doc.get("plugins").and_then(serde_json::Value::as_object) else {
1230 return;
1231 };
1232 for (id, entries) in plugins {
1233 if !plugin_allowed(&enabled, id) {
1234 continue;
1235 }
1236 let Some(list) = entries.as_array() else {
1237 continue;
1238 };
1239 for entry in list {
1240 let Some(install) = entry
1241 .get("installPath")
1242 .and_then(serde_json::Value::as_str)
1243 .filter(|path| !path.is_empty())
1244 else {
1245 continue;
1246 };
1247 let plugin_json = PathBuf::from(install).join(".claude-plugin/plugin.json");
1248 let Some(body) = read_capped(&plugin_json) else {
1249 continue;
1250 };
1251 let Ok(spec) = serde_json::from_str::<serde_json::Value>(&body) else {
1252 continue;
1253 };
1254 let Some(name) = spec.get("name").and_then(serde_json::Value::as_str) else {
1255 continue;
1256 };
1257 if name.is_empty() {
1258 continue;
1259 }
1260 let Some(servers) = spec.get("mcpServers") else {
1261 continue;
1262 };
1263 for key in json_seat_keys(servers) {
1264 remember_key(out, &format!("plugin_{name}_{key}"));
1265 }
1266 }
1267 }
1268}
1269
1270fn enabled_plugins(path: &Path) -> Option<serde_json::Map<String, serde_json::Value>> {
1271 let text = read_capped(path)?;
1272 let doc: serde_json::Value = serde_json::from_str(&text).ok()?;
1273 doc.get("enabledPlugins")
1274 .and_then(serde_json::Value::as_object)
1275 .cloned()
1276}
1277
1278fn plugin_allowed(enabled: &Option<serde_json::Map<String, serde_json::Value>>, id: &str) -> bool {
1279 match enabled {
1280 None => true,
1281 Some(map) => map.get(id).and_then(serde_json::Value::as_bool) == Some(true),
1282 }
1283}
1284
1285fn read_capped(path: &Path) -> Option<String> {
1286 use std::io::Read;
1287 use std::os::unix::fs::OpenOptionsExt;
1288 let file = std::fs::OpenOptions::new()
1289 .read(true)
1290 .custom_flags(libc::O_NONBLOCK)
1291 .open(path)
1292 .ok()?;
1293 let meta = file.metadata().ok()?;
1294 if !meta.is_file() || meta.len() > MCP_CONFIG_CAP {
1295 return None;
1296 }
1297 let mut text = String::new();
1298 file.take(MCP_CONFIG_CAP).read_to_string(&mut text).ok()?;
1299 Some(text)
1300}
1301
1302pub(crate) fn payload_command_is_seat(command: &str) -> bool {
1306 let mut parts = command.split_whitespace();
1307 let Some(bin) = parts.next() else {
1308 return false;
1309 };
1310 let args: Vec<String> = parts.map(str::to_string).collect();
1311 command_launches_seat(bin, &args)
1312}
1313
1314fn command_launches_seat(command: &str, args: &[String]) -> bool {
1315 let base = Path::new(command)
1316 .file_name()
1317 .and_then(|name| name.to_str())
1318 .unwrap_or(command);
1319 if base == crate::MCP_SERVER_COMMAND {
1320 return true;
1321 }
1322 if base == "ljos-plugin" && args.iter().any(|arg| arg == crate::MCP_SERVER_COMMAND) {
1324 return true;
1325 }
1326 base == "ljos" && args.first().map(String::as_str) == Some("mcp")
1327}
1328
1329fn claude_tool_segment(key: &str) -> String {
1333 key.chars()
1334 .map(|c| {
1335 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
1336 c
1337 } else {
1338 '_'
1339 }
1340 })
1341 .collect()
1342}
1343
1344fn remember_key(out: &mut BTreeSet<String>, key: &str) {
1345 if key.is_empty() {
1346 return;
1347 }
1348 let norm = claude_tool_segment(key);
1349 if norm != key {
1350 out.insert(norm);
1351 }
1352 out.insert(key.to_string());
1353}
1354
1355fn entry_is_off(disabled: Option<bool>, enabled: Option<bool>) -> bool {
1356 disabled == Some(true) || enabled == Some(false)
1357}
1358
1359fn json_entry_launches(value: &serde_json::Value) -> bool {
1360 let Some(obj) = value.as_object() else {
1361 return false;
1362 };
1363 if entry_is_off(
1364 obj.get("disabled").and_then(serde_json::Value::as_bool),
1365 obj.get("enabled").and_then(serde_json::Value::as_bool),
1366 ) {
1367 return false;
1368 }
1369 command_and_args(value).is_some_and(|spec| command_launches_seat(&spec.command, &spec.args))
1370}
1371
1372fn json_seat_keys(value: &serde_json::Value) -> BTreeSet<String> {
1373 let mut out = BTreeSet::new();
1374 fn walk(value: &serde_json::Value, out: &mut BTreeSet<String>) {
1375 if let Some(list) = value.as_array() {
1376 for item in list {
1377 walk(item, out);
1378 }
1379 return;
1380 }
1381 let Some(obj) = value.as_object() else {
1382 return;
1383 };
1384 for (key, child) in obj {
1385 if json_entry_launches(child) {
1386 remember_key(out, key);
1387 }
1388 walk(child, out);
1389 }
1390 }
1391 walk(value, &mut out);
1392 out
1393}
1394
1395fn toml_bool(value: Option<&toml::Value>) -> Option<bool> {
1396 value.and_then(toml::Value::as_bool)
1397}
1398
1399fn toml_strings(value: Option<&toml::Value>) -> Vec<String> {
1400 value
1401 .and_then(toml::Value::as_array)
1402 .map(|items| {
1403 items
1404 .iter()
1405 .filter_map(|item| item.as_str().map(str::to_string))
1406 .collect()
1407 })
1408 .unwrap_or_default()
1409}
1410
1411fn toml_entry_launches(value: &toml::Value) -> bool {
1412 let Some(table) = value.as_table() else {
1413 return false;
1414 };
1415 if entry_is_off(
1416 toml_bool(table.get("disabled")),
1417 toml_bool(table.get("enabled")),
1418 ) {
1419 return false;
1420 }
1421 let args = toml_strings(table.get("args"));
1422 let Some(cmd) = table.get("command").or_else(|| table.get("cmd")) else {
1423 return false;
1424 };
1425 match cmd {
1426 toml::Value::String(command) => command_launches_seat(command.trim(), &args),
1427 toml::Value::Array(parts) => {
1428 let mut argv: Vec<String> = parts
1429 .iter()
1430 .filter_map(|item| item.as_str().map(str::to_string))
1431 .collect();
1432 if argv.is_empty() {
1433 return false;
1434 }
1435 let command = argv.remove(0);
1436 argv.extend(args);
1437 command_launches_seat(command.trim(), &argv)
1438 }
1439 _ => false,
1440 }
1441}
1442
1443fn toml_seat_keys(value: &toml::Value) -> BTreeSet<String> {
1444 let mut out = BTreeSet::new();
1445 fn walk(value: &toml::Value, out: &mut BTreeSet<String>) {
1446 let Some(table) = value.as_table() else {
1447 return;
1448 };
1449 for (key, child) in table {
1450 if toml_entry_launches(child) {
1451 remember_key(out, key);
1452 }
1453 walk(child, out);
1454 }
1455 }
1456 walk(value, &mut out);
1457 out
1458}
1459
1460fn marker_server_key(marker: &str) -> Option<String> {
1461 let header = marker.trim();
1462 if let Some(inner) = header
1463 .strip_prefix('[')
1464 .and_then(|rest| rest.strip_suffix(']'))
1465 {
1466 let name = inner.split('.').next_back()?.trim();
1467 return (!name.is_empty()).then(|| name.to_string());
1468 }
1469 let line = header.lines().next()?.trim().trim_end_matches(':').trim();
1470 if line.is_empty() || line.contains(' ') || line.contains('\t') {
1471 return None;
1472 }
1473 Some(line.to_string())
1474}
1475
1476fn text_marker_key(text: &str, marker: &str) -> Option<String> {
1477 let window = entry_text(text, marker)?;
1478 let command = keyed_value(&window, "command").or_else(|| keyed_value(&window, "cmd"))?;
1479 let args = keyed_value(&window, "args").into_iter().collect::<Vec<_>>();
1480 if !command_launches_seat(command.trim(), &args) {
1481 return None;
1482 }
1483 marker_server_key(marker)
1484}
1485
1486fn read_json_launch(path: &Path, pointer: &str) -> Result<Option<LaunchSpec>> {
1487 let text = fs::read_to_string(path).with_context(|| path.display().to_string())?;
1488 if text.trim().is_empty() {
1489 return Ok(None);
1490 }
1491 let doc: serde_json::Value =
1492 serde_json::from_str(&text).with_context(|| format!("{}: not JSON", path.display()))?;
1493 let Some(entry) = doc.pointer(pointer) else {
1494 return Ok(None);
1495 };
1496 Ok(command_and_args(entry))
1497}
1498
1499fn command_and_args(entry: &serde_json::Value) -> Option<LaunchSpec> {
1500 let extra = string_list(entry.get("args"));
1501 let env_path = entry
1502 .get("env")
1503 .and_then(serde_json::Value::as_object)
1504 .and_then(|env| env.get("PATH"))
1505 .and_then(serde_json::Value::as_str)
1506 .map(str::to_string);
1507 match entry.get("command").or_else(|| entry.get("cmd"))? {
1508 serde_json::Value::String(command) => {
1509 let command = command.trim();
1510 (!command.is_empty()).then(|| LaunchSpec {
1511 command: command.to_string(),
1512 args: extra,
1513 env_path,
1514 })
1515 }
1516 serde_json::Value::Array(parts) => {
1517 let mut argv: Vec<String> = parts
1518 .iter()
1519 .filter_map(|v| v.as_str().map(str::to_string))
1520 .collect();
1521 let command = argv.first()?.trim().to_string();
1522 if command.is_empty() {
1523 return None;
1524 }
1525 argv.remove(0);
1526 argv.extend(extra);
1527 Some(LaunchSpec {
1528 command,
1529 args: argv,
1530 env_path,
1531 })
1532 }
1533 _ => None,
1534 }
1535}
1536
1537fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
1538 value
1539 .and_then(serde_json::Value::as_array)
1540 .map(|arr| {
1541 arr.iter()
1542 .filter_map(|v| v.as_str().map(str::to_string))
1543 .collect()
1544 })
1545 .unwrap_or_default()
1546}
1547
1548fn read_text_launch(path: &Path, marker: &str) -> Option<LaunchSpec> {
1550 let text = fs::read_to_string(path).ok()?;
1551 let window = entry_text(&text, marker)?;
1552 let command = keyed_value(&window, "command").or_else(|| keyed_value(&window, "cmd"))?;
1553 if command.is_empty() {
1554 None
1555 } else {
1556 Some(LaunchSpec {
1557 command,
1558 args: Vec::new(),
1559 env_path: keyed_value(&window, "PATH"),
1560 })
1561 }
1562}
1563
1564fn entry_text(text: &str, marker: &str) -> Option<String> {
1568 if !text.contains(marker) {
1569 return None;
1570 }
1571 let header = marker.trim();
1572 if header.starts_with('[') && header.ends_with(']') && !header.contains('\n') {
1573 return toml_entry(text, header);
1574 }
1575 yaml_entry(text, marker)
1576}
1577
1578fn toml_entry(text: &str, header: &str) -> Option<String> {
1579 let name = header.trim_matches(|c| c == '[' || c == ']').trim();
1580 if name.is_empty() {
1581 return None;
1582 }
1583 let mut out = String::new();
1584 let mut keeping = false;
1585 let mut seen = false;
1586 for line in text.split_inclusive('\n') {
1587 if let Some(table) = toml_table_name(line) {
1588 keeping = table == name
1589 || table
1590 .strip_prefix(name)
1591 .is_some_and(|rest| rest.starts_with('.'));
1592 if keeping {
1593 seen = true;
1594 }
1595 }
1596 if keeping {
1597 out.push_str(line);
1598 }
1599 }
1600 seen.then_some(out)
1601}
1602
1603fn toml_table_name(line: &str) -> Option<&str> {
1604 let trimmed = line.trim();
1605 if trimmed.starts_with('#') {
1606 return None;
1607 }
1608 let header = trimmed.split('#').next().unwrap_or(trimmed).trim();
1609 let inner = if let Some(rest) = header.strip_prefix("[[") {
1610 rest.strip_suffix("]]")?
1611 } else {
1612 let rest = header.strip_prefix('[')?;
1613 let rest = rest.strip_suffix(']')?;
1614 if rest.contains('[') {
1615 return None;
1616 }
1617 rest
1618 };
1619 let name = inner.trim();
1620 (!name.is_empty()).then_some(name)
1621}
1622
1623fn yaml_entry(text: &str, marker: &str) -> Option<String> {
1624 let found = text.find(marker)?;
1625 let start = if marker.starts_with('\n') {
1626 found + 1
1627 } else {
1628 text[..found].rfind('\n').map_or(0, |at| at + 1)
1629 };
1630 let rest = &text[start..];
1631 let mut lines = rest.split_inclusive('\n');
1632 let first = lines.next()?;
1633 let indent = first.chars().take_while(|c| *c == ' ').count();
1634 let mut out = String::new();
1635 out.push_str(first);
1636 if !first.ends_with('\n') {
1637 out.push('\n');
1638 }
1639 for line in lines {
1640 if line.trim().is_empty() {
1641 out.push_str(line);
1642 continue;
1643 }
1644 let ind = line.chars().take_while(|c| *c == ' ').count();
1645 if ind <= indent {
1646 break;
1647 }
1648 out.push_str(line);
1649 }
1650 Some(out)
1651}
1652
1653fn expand_env_path(value: &str, expand: Expand) -> String {
1656 let path = std::env::var("PATH").unwrap_or_default();
1657 match expand {
1658 Expand::Literal => value.to_string(),
1659 Expand::Opencode => replace_opencode_env(value, "PATH", &path),
1660 Expand::Cursor => expand_cursor(value),
1661 Expand::Claude => expand_claude(value),
1662 Expand::Shell => replace_dollar_path(
1663 &value
1664 .replace("${env:PATH}", &path)
1665 .replace("${PATH}", &path),
1666 &path,
1667 ),
1668 }
1669}
1670
1671fn replace_dollar_path(value: &str, path: &str) -> String {
1672 let mut out = String::new();
1673 let mut rest = value;
1674 while let Some(at) = rest.find("$PATH") {
1675 let after = at + "$PATH".len();
1676 let boundary = rest[after..].chars().next().is_none_or(|c| !is_key_char(c));
1677 out.push_str(&rest[..at]);
1678 if boundary {
1679 out.push_str(path);
1680 } else {
1681 out.push_str("$PATH");
1682 }
1683 rest = &rest[after..];
1684 }
1685 out.push_str(rest);
1686 out
1687}
1688
1689fn is_key_char(c: char) -> bool {
1690 c.is_ascii_alphanumeric() || c == '_' || c == '-'
1691}
1692
1693fn keyed_value(window: &str, key: &str) -> Option<String> {
1694 let mut rest = window;
1695 while let Some(at) = rest.find(key) {
1696 let before = at == 0 || !is_key_byte(rest.as_bytes()[at - 1]);
1697 let after_idx = at + key.len();
1698 let after = after_idx >= rest.len() || !is_key_byte(rest.as_bytes()[after_idx]);
1699 if before && after {
1700 let tail = rest[after_idx..].trim_start();
1701 if let Some(value) = tail.strip_prefix('=').or_else(|| tail.strip_prefix(':')) {
1702 let parsed = parse_scalar(value.trim_start());
1703 if !parsed.is_empty() {
1704 return Some(parsed);
1705 }
1706 }
1707 }
1708 rest = &rest[after_idx..];
1709 }
1710 None
1711}
1712
1713fn is_key_byte(b: u8) -> bool {
1714 is_key_char(char::from(b))
1715}
1716
1717fn parse_scalar(rest: &str) -> String {
1718 let mut chars = rest.chars();
1719 match chars.next() {
1720 Some(q @ ('"' | '\'')) => {
1721 let raw = chars.as_str();
1722 match raw.find(q) {
1723 Some(end) => raw[..end].to_string(),
1724 None => String::new(),
1725 }
1726 }
1727 _ => rest
1728 .split_whitespace()
1729 .next()
1730 .unwrap_or("")
1731 .trim_end_matches(',')
1732 .to_string(),
1733 }
1734}
1735
1736fn expand_launch(command: &str, expand: Expand) -> String {
1738 match expand {
1739 Expand::Literal => command.to_string(),
1740 Expand::Opencode => expand_opencode_home(command),
1741 Expand::Cursor => expand_cursor(command),
1742 Expand::Claude => expand_claude(command),
1743 Expand::Shell => expand_shell_home(command),
1744 }
1745}
1746
1747fn expand_cursor(text: &str) -> String {
1750 expand_braced(text, |inner| match inner {
1751 "userHome" => crate::home().ok().map(|h| h.display().to_string()),
1752 _ => std::env::var(inner.strip_prefix("env:")?).ok(),
1753 })
1754}
1755
1756fn expand_claude(text: &str) -> String {
1759 expand_braced(text, |inner| {
1760 let (name, default) = match inner.split_once(":-") {
1761 Some((name, default)) => (name, Some(default)),
1762 None => (inner, None),
1763 };
1764 if !is_env_name(name) {
1765 return None;
1766 }
1767 std::env::var(name)
1768 .ok()
1769 .or_else(|| default.map(str::to_string))
1770 })
1771}
1772
1773fn is_env_name(name: &str) -> bool {
1774 let mut chars = name.chars();
1775 chars
1776 .next()
1777 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
1778 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1779}
1780
1781fn expand_braced(text: &str, resolve: impl Fn(&str) -> Option<String>) -> String {
1784 let mut out = String::new();
1785 let mut rest = text;
1786 while let Some(at) = rest.find("${") {
1787 let inner = &rest[at + 2..];
1788 let Some(len) = inner.find('}') else {
1789 break;
1790 };
1791 out.push_str(&rest[..at]);
1792 match resolve(&inner[..len]) {
1793 Some(value) => out.push_str(&value),
1794 None => out.push_str(&rest[at..at + len + 3]),
1795 }
1796 rest = &inner[len + 1..];
1797 }
1798 out.push_str(rest);
1799 out
1800}
1801
1802fn expand_opencode_home(command: &str) -> String {
1805 let Ok(home) = crate::home() else {
1806 return command.to_string();
1807 };
1808 replace_opencode_env(command, "HOME", &home.display().to_string())
1809}
1810
1811fn replace_opencode_env(text: &str, name: &str, value: &str) -> String {
1814 let token = format!("{{env:{name}}}");
1815 let mut out = String::new();
1816 let mut rest = text;
1817 while let Some(at) = rest.find(&token) {
1818 let shell_form = at > 0 && rest.as_bytes()[at - 1] == b'$';
1819 if shell_form {
1820 out.push_str(&rest[..at + token.len()]);
1821 } else {
1822 out.push_str(&rest[..at]);
1823 out.push_str(value);
1824 }
1825 rest = &rest[at + token.len()..];
1826 }
1827 out.push_str(rest);
1828 out
1829}
1830
1831fn expand_shell_home(command: &str) -> String {
1833 let Ok(home) = crate::home() else {
1834 return command.to_string();
1835 };
1836 let home = home.display().to_string();
1837 let mut expanded = if let Some(rest) = command.strip_prefix("~/") {
1838 format!("{home}/{rest}")
1839 } else if command == "~" {
1840 home.clone()
1841 } else {
1842 command.to_string()
1843 };
1844 for token in ["${env:HOME}", "${userHome}", "${HOME}", "$HOME"] {
1845 if expanded.contains(token) {
1846 expanded = expanded.replace(token, &home);
1847 }
1848 }
1849 expanded
1850}
1851
1852fn launch_check(file: &McpFile, launch: &LaunchSpec) -> LaunchCheck {
1853 let command = expand_launch(&launch.command, file.expand);
1854 let env_path = launch
1855 .env_path
1856 .as_deref()
1857 .map(|path| expand_env_path(path, file.expand));
1858 let search_path = env_path.as_deref().or(file.launch_path.as_deref());
1859 let found_path = command_path(&command, search_path);
1860 let shell_path = if search_path.is_none() {
1861 shell_command_path(&command)
1862 } else {
1863 None
1864 };
1865 let pathy = command.contains('/') || Path::new(&command).is_absolute();
1866 let chosen = found_path.clone().or_else(|| shell_path.clone());
1867 let Some(chosen) = chosen else {
1868 return LaunchCheck::Missing(if pathy {
1869 format!("{} does not exist", launch.command)
1870 } else if env_path.is_some() {
1871 format!("{} is not on env.PATH", launch.command)
1872 } else if let Some(path) = &file.launch_path {
1873 format!("{} is not on the app's PATH, {path}", launch.command)
1874 } else {
1875 format!("{} is not on PATH", launch.command)
1876 });
1877 };
1878 if found_path.is_none() {
1879 return LaunchCheck::Warn(format!(
1880 "{} is not on this process PATH, but $SHELL -lc finds {}",
1881 launch.command,
1882 chosen.display()
1883 ));
1884 }
1885 let name = Path::new(&command).file_name().and_then(|n| n.to_str());
1886 if name == Some(crate::MCP_SERVER_COMMAND) {
1887 if let Ok(own) = crate::server_path() {
1888 if !same_binary(&chosen, &own) {
1889 return LaunchCheck::Warn(if pathy {
1890 format!("{} is not this install's {}", launch.command, own.display())
1891 } else {
1892 format!(
1893 "{} resolves to {}, not {}",
1894 launch.command,
1895 chosen.display(),
1896 own.display()
1897 )
1898 });
1899 }
1900 }
1901 }
1902 if name == Some("ljos-plugin") {
1903 if let Some(bin) = launch.args.first() {
1904 if !bin.contains('/')
1905 && command_path(bin, search_path).is_none()
1906 && !home_bin_exists(bin)
1907 {
1908 return LaunchCheck::Missing(format!("{bin} is not installed"));
1909 }
1910 }
1911 }
1912 LaunchCheck::Ready
1913}
1914
1915fn command_path(command: &str, search_path: Option<&str>) -> Option<PathBuf> {
1916 let path = Path::new(command);
1917 if command.contains('/') || path.is_absolute() {
1918 return path.is_file().then(|| path.to_path_buf());
1919 }
1920 match search_path {
1921 Some(path) => path_lookup(command, path),
1922 None => which::which(command).ok(),
1923 }
1924}
1925
1926fn path_lookup(command: &str, path: &str) -> Option<PathBuf> {
1927 if command.contains('/') || command.is_empty() {
1928 return None;
1929 }
1930 for dir in std::env::split_paths(path) {
1931 if dir.as_os_str().is_empty() {
1932 continue;
1933 }
1934 let candidate = dir.join(command);
1935 if candidate.is_file() {
1936 return Some(candidate);
1937 }
1938 }
1939 None
1940}
1941
1942fn shell_command_path(command: &str) -> Option<PathBuf> {
1943 if command.contains('/') || command.is_empty() {
1944 return None;
1945 }
1946 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
1947 let out = Command::new(shell)
1948 .arg("-lc")
1949 .arg("command -v -- \"$1\"")
1950 .arg("_")
1951 .arg(command)
1952 .stdin(std::process::Stdio::null())
1953 .output()
1954 .ok()?;
1955 if !out.status.success() {
1956 return None;
1957 }
1958 let stdout = String::from_utf8_lossy(&out.stdout);
1959 let found = stdout.lines().next().unwrap_or("").trim();
1960 if found.is_empty() {
1961 None
1962 } else {
1963 Some(PathBuf::from(found))
1964 }
1965}
1966
1967fn same_binary(a: &Path, b: &Path) -> bool {
1968 if a == b {
1969 return true;
1970 }
1971 match (fs::canonicalize(a), fs::canonicalize(b)) {
1972 (Ok(left), Ok(right)) => left == right,
1973 _ => false,
1974 }
1975}
1976
1977fn home_bin_exists(name: &str) -> bool {
1978 let Ok(home) = crate::home() else {
1979 return false;
1980 };
1981 [home.join(".local/bin"), home.join(".cargo/bin")]
1982 .into_iter()
1983 .any(|dir| dir.join(name).is_file())
1984}
1985
1986fn ensure_binaries(bin_dir: &Path, force: bool) -> Result<String> {
1987 if let Some(dir) = std::env::var_os("LJOS_RELEASE_DIR").filter(|p| !p.is_empty()) {
1988 return install_from_dir(bin_dir, Path::new(&dir));
1989 }
1990 if force {
1991 for name in REQUIRED_BINS.iter().chain(OPTIONAL_BINS.iter()) {
1992 let _ = fs::remove_file(bin_dir.join(name));
1993 }
1994 }
1995 if bins_ready(bin_dir) {
1996 return Ok(format!("binaries: already in {}", bin_dir.display()));
1997 }
1998 run_installer(bin_dir)?;
1999 if !bins_ready(bin_dir) {
2000 bail!(
2001 "cloud up: {} is missing ljos, ljos-mcp or ljos-policyd after install",
2002 bin_dir.display()
2003 );
2004 }
2005 Ok(format!("binaries: installed into {}", bin_dir.display()))
2006}
2007
2008fn bins_ready(dir: &Path) -> bool {
2009 REQUIRED_BINS.iter().all(|name| {
2010 let path = dir.join(name);
2011 path.is_file() && executable(&path)
2012 })
2013}
2014
2015fn executable(path: &Path) -> bool {
2016 use std::os::unix::fs::PermissionsExt;
2017 fs::symlink_metadata(path)
2020 .map(|meta| meta.file_type().is_file() && meta.permissions().mode() & 0o111 != 0)
2021 .unwrap_or(false)
2022}
2023
2024fn install_from_dir(bin_dir: &Path, src: &Path) -> Result<String> {
2025 let sums_path = src.join("SHA256SUMS");
2026 let text =
2027 fs::read_to_string(&sums_path).with_context(|| format!("read {}", sums_path.display()))?;
2028 let mut want = BTreeMap::new();
2029 for (n, line) in text.lines().enumerate() {
2030 let line = line.trim();
2031 if line.is_empty() || line.starts_with('#') {
2032 continue;
2033 }
2034 let mut parts = line.split_whitespace();
2035 let Some(hash) = parts.next() else {
2036 bail!("{}:{}: no hash", sums_path.display(), n + 1);
2037 };
2038 let Some(name) = parts.next() else {
2039 bail!("{}:{}: no name", sums_path.display(), n + 1);
2040 };
2041 let name = name.trim_start_matches('*');
2042 want.insert(name.to_string(), hash.to_ascii_lowercase());
2043 }
2044 for name in REQUIRED_BINS {
2045 if !want.contains_key(*name) {
2046 bail!("{name} is not listed in {}", sums_path.display());
2047 }
2048 }
2049 let mut install = Vec::new();
2050 for name in REQUIRED_BINS
2051 .iter()
2052 .copied()
2053 .chain(OPTIONAL_BINS.iter().copied())
2054 {
2055 if want.contains_key(name) {
2056 install.push(name);
2057 }
2058 }
2059 for name in &install {
2060 let path = src.join(name);
2061 if !path.is_file() {
2062 bail!(
2063 "{} is listed in the checksums and is not in {}",
2064 name,
2065 src.display()
2066 );
2067 }
2068 let got = sha256_file(&path)?;
2069 let expect = &want[*name];
2070 if got != *expect {
2071 bail!(
2072 "{name}: checksum mismatch (published {expect}, file {got}); nothing was installed"
2073 );
2074 }
2075 }
2076 fs::create_dir_all(bin_dir)?;
2077 for name in &install {
2078 let dest = bin_dir.join(name);
2079 fs::copy(src.join(name), &dest)
2080 .with_context(|| format!("copy {name} to {}", dest.display()))?;
2081 use std::os::unix::fs::PermissionsExt;
2082 fs::set_permissions(&dest, fs::Permissions::from_mode(0o755))?;
2083 }
2084 Ok(format!(
2085 "binaries: installed {} into {} (checksums match)",
2086 install.join(", "),
2087 bin_dir.display()
2088 ))
2089}
2090
2091fn sha256_file(path: &Path) -> Result<String> {
2092 let mut cmd = Command::new("sha256sum");
2093 cmd.arg(path).stdin(std::process::Stdio::null());
2094 let out = match cmd.output() {
2095 Ok(out) if out.status.success() => out,
2096 _ => {
2097 let out = Command::new("shasum")
2098 .args(["-a", "256"])
2099 .arg(path)
2100 .stdin(std::process::Stdio::null())
2101 .output()
2102 .with_context(|| format!("sha256 {}", path.display()))?;
2103 if !out.status.success() {
2104 bail!(
2105 "sha256 {}: {}",
2106 path.display(),
2107 String::from_utf8_lossy(&out.stderr).trim()
2108 );
2109 }
2110 out
2111 }
2112 };
2113 let text = String::from_utf8_lossy(&out.stdout);
2114 let hash = text
2115 .split_whitespace()
2116 .next()
2117 .context("sha256 printed nothing")?;
2118 Ok(hash.to_ascii_lowercase())
2119}
2120
2121fn ensure_encoder(bin_dir: &Path, force: bool) -> Result<String> {
2126 if !cloud_vm() {
2127 return Ok("encoder: not a cloud VM, so packset-embed is not downloaded".into());
2128 }
2129 let Some(target) = encoder_target() else {
2130 return Ok(format!(
2131 "encoder: no release build of packset-embed for {}-{}; search stays lexical",
2132 std::env::consts::ARCH,
2133 std::env::consts::OS
2134 ));
2135 };
2136 let lib = bin_dir
2137 .parent()
2138 .context("cloud up: the binary directory has no parent for lib/")?
2139 .join(ENCODER_LIB);
2140 let bin = bin_dir.join("packset-embed");
2141 if !force && executable(&bin) && lib.join(ENCODER_SONAME).is_file() {
2142 return Ok(format!(
2143 "encoder: packset-embed already in {}",
2144 bin_dir.display()
2145 ));
2146 }
2147 let parent = lib.parent().context("cloud up: no lib directory")?;
2148 let stage = parent.join(format!(".onnxruntime-{}", std::process::id()));
2149 let _ = fs::remove_dir_all(&stage);
2150 fs::create_dir_all(&stage).with_context(|| format!("create {}", stage.display()))?;
2151 let said = install_encoder(&stage, target, bin_dir, &lib);
2152 let _ = fs::remove_dir_all(&stage);
2153 said
2154}
2155
2156fn install_encoder(stage: &Path, target: &str, bin_dir: &Path, lib: &Path) -> Result<String> {
2157 let mut archives = Vec::new();
2158 for base in ENCODER_ARCHIVES {
2159 let name = format!("{base}-{target}.tar.gz");
2160 let file = stage.join(&name);
2161 let sums = stage.join(format!("{name}.sha256"));
2162 match release_asset(&name, &file) {
2163 Ok(true) => {}
2164 Ok(false) => {
2165 return Ok(format!(
2166 "encoder: {} has no {name}; search stays lexical",
2167 release_source()
2168 ))
2169 }
2170 Err(e) => return Ok(format!("encoder: {e}; search stays lexical")),
2171 }
2172 match release_asset(&format!("{name}.sha256"), &sums) {
2173 Ok(true) => {}
2174 Ok(false) => bail!("{name} has no published checksum; nothing was installed"),
2175 Err(e) => return Ok(format!("encoder: {e}; nothing was installed")),
2176 }
2177 let want = crate::upgrade::digest_of(&fs::read_to_string(&sums)?).with_context(|| {
2178 format!("{name}.sha256 is not a sha256 line; nothing was installed")
2179 })?;
2180 let got = sha256_file(&file)?;
2181 if got != want {
2182 bail!(
2183 "{name}: checksum mismatch (published {want}, file {got}); nothing was installed"
2184 );
2185 }
2186 archives.push(file);
2187 }
2188 for file in &archives {
2193 refuse_unsafe_archive(file)?;
2194 }
2195 let tree = stage.join("tree");
2196 fs::create_dir_all(&tree)?;
2197 for file in &archives {
2198 let out = Command::new("tar")
2199 .arg("-xzf")
2200 .arg(file)
2201 .arg("-C")
2202 .arg(&tree)
2203 .stdin(std::process::Stdio::null())
2204 .output()
2205 .context("tar")?;
2206 if !out.status.success() {
2207 bail!(
2208 "tar {}: {}; nothing was installed",
2209 file.display(),
2210 String::from_utf8_lossy(&out.stderr).trim()
2211 );
2212 }
2213 }
2214 refuse_unsafe_tree(&tree)?;
2215 let new_lib = tree.join(ENCODER_LIB);
2216 let new_bin = tree.join("bin/packset-embed");
2217 if !regular_file(&new_lib.join(ENCODER_SONAME)) || !regular_file(&new_bin) {
2218 bail!("the encoder archives lack bin/packset-embed or {ENCODER_LIB}/{ENCODER_SONAME}; nothing was installed");
2219 }
2220 use std::os::unix::fs::PermissionsExt;
2226 if let Err(err) = fs::set_permissions(&new_bin, fs::Permissions::from_mode(0o755)) {
2227 bail!("encoder install failed ({err}); nothing was installed");
2228 }
2229 if let Err(err) = fs::create_dir_all(bin_dir) {
2230 bail!("encoder install failed ({err}); nothing was installed");
2231 }
2232 let bin = bin_dir.join("packset-embed");
2233 swap_encoder(stage, &new_lib, &new_bin, lib, &bin)?;
2234 Ok(format!(
2235 "encoder: installed {} and ONNX Runtime in {} (checksums match)",
2236 bin.display(),
2237 lib.display()
2238 ))
2239}
2240
2241fn path_present(path: &Path) -> Result<bool> {
2244 match fs::symlink_metadata(path) {
2245 Ok(_) => Ok(true),
2246 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
2247 Err(err) => bail!("encoder install failed ({err}); nothing was installed"),
2248 }
2249}
2250
2251fn swap_encoder(
2254 stage: &Path,
2255 new_lib: &Path,
2256 new_bin: &Path,
2257 lib: &Path,
2258 bin: &Path,
2259) -> Result<()> {
2260 let backup_lib = stage.join("old-lib");
2261 let backup_bin = stage.join("old-bin");
2262 let had_lib = path_present(lib)?;
2263 let had_bin = path_present(bin)?;
2264 let mut bin_aside = false;
2265
2266 let fail = |bin_aside: bool, cause: &str| -> Result<()> {
2267 let lib_err = had_lib
2268 .then(|| fs::rename(&backup_lib, lib).err())
2269 .flatten();
2270 let bin_err = bin_aside
2271 .then(|| fs::rename(&backup_bin, bin).err())
2272 .flatten();
2273 match lib_err.or(bin_err) {
2274 None => bail!("encoder install failed ({cause}); nothing was installed"),
2275 Some(err) => bail!(
2276 "encoder install failed ({cause}); restoring the previous encoder also failed: {err}"
2277 ),
2278 }
2279 };
2280
2281 if had_lib {
2282 if let Err(err) = fs::rename(lib, &backup_lib) {
2283 bail!("encoder install failed ({err}); nothing was installed");
2284 }
2285 }
2286 if had_bin {
2287 if let Err(err) = fs::rename(bin, &backup_bin) {
2288 return fail(false, &err.to_string());
2289 }
2290 bin_aside = true;
2291 }
2292 if let Err(err) = fs::rename(new_lib, lib) {
2293 return fail(bin_aside, &err.to_string());
2294 }
2295 if let Err(err) = fs::rename(new_bin, bin) {
2296 if fs::rename(lib, new_lib).is_err() {
2299 let _ = fs::remove_dir_all(lib);
2300 }
2301 return fail(bin_aside, &err.to_string());
2302 }
2303 Ok(())
2304}
2305
2306fn cloud_vm() -> bool {
2309 let rt = runtime();
2310 !rt.ci && (rt.cloud_id || rt.api_socket)
2311}
2312
2313fn encoder_target() -> Option<&'static str> {
2314 match (std::env::consts::ARCH, std::env::consts::OS) {
2315 ("x86_64", "linux") => Some("x86_64-unknown-linux-gnu"),
2316 _ => None,
2317 }
2318}
2319
2320fn release_source() -> String {
2321 match std::env::var_os("LJOS_RELEASE_DIR").filter(|p| !p.is_empty()) {
2322 Some(dir) => Path::new(&dir).display().to_string(),
2323 None => format!("release v{}", env!("CARGO_PKG_VERSION")),
2324 }
2325}
2326
2327fn release_asset(name: &str, to: &Path) -> Result<bool> {
2330 if let Some(dir) = std::env::var_os("LJOS_RELEASE_DIR").filter(|p| !p.is_empty()) {
2331 let src = Path::new(&dir).join(name);
2332 if !src.is_file() {
2333 return Ok(false);
2334 }
2335 fs::copy(&src, to).with_context(|| format!("copy {}", src.display()))?;
2336 return Ok(true);
2337 }
2338 let url = format!(
2339 "https://github.com/{}/releases/download/v{}/{name}",
2340 crate::upgrade::RELEASE_REPO,
2341 env!("CARGO_PKG_VERSION")
2342 );
2343 let resp = match ureq::get(&url).timeout(Duration::from_secs(300)).call() {
2344 Ok(resp) => resp,
2345 Err(ureq::Error::Status(404, _)) => return Ok(false),
2346 Err(e) => bail!("{url}: {e}"),
2347 };
2348 let mut file = fs::File::create(to).with_context(|| format!("write {}", to.display()))?;
2349 std::io::copy(
2350 &mut std::io::Read::take(resp.into_reader(), 512 * 1024 * 1024),
2351 &mut file,
2352 )
2353 .with_context(|| format!("read {url}"))?;
2354 Ok(true)
2355}
2356
2357fn run_installer(bin_dir: &Path) -> Result<()> {
2358 let script = installer_script()?;
2359 let mut cmd = Command::new("sh");
2360 cmd.arg(&script)
2361 .arg("--no-onboard")
2362 .arg("--no-embed")
2363 .arg("--bin-dir")
2364 .arg(bin_dir)
2365 .stdin(std::process::Stdio::null());
2366 let out = cmd
2367 .output()
2368 .with_context(|| format!("run {}", script.display()))?;
2369 if out.status.success() {
2370 return Ok(());
2371 }
2372 let said = String::from_utf8_lossy(&out.stderr);
2373 let said = if said.trim().is_empty() {
2374 String::from_utf8_lossy(&out.stdout)
2375 } else {
2376 said
2377 };
2378 bail!("install.sh: {}", said.trim());
2379}
2380
2381fn installer_script() -> Result<PathBuf> {
2382 if let Some(path) = std::env::var_os("LJOS_INSTALL_SH").filter(|p| !p.is_empty()) {
2383 let path = PathBuf::from(path);
2384 if !path.is_file() {
2385 bail!("LJOS_INSTALL_SH {} is not a file", path.display());
2386 }
2387 return Ok(path);
2388 }
2389 let url = std::env::var("LJOS_INSTALL_URL").unwrap_or_else(|_| INSTALL_URL.to_string());
2390 let agent = ureq::AgentBuilder::new()
2391 .timeout(Duration::from_secs(60))
2392 .build();
2393 let text = agent
2394 .get(&url)
2395 .call()
2396 .map_err(|e| anyhow::anyhow!("{url}: {e}"))?
2397 .into_string()
2398 .with_context(|| format!("read {url}"))?;
2399 let path = std::env::temp_dir().join(format!("ljos-install-{}.sh", std::process::id()));
2400 let mut file = fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
2401 file.write_all(text.as_bytes())?;
2402 use std::os::unix::fs::PermissionsExt;
2403 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
2404 Ok(path)
2405}
2406
2407fn ensure_tracker(remote: &str, dest: &Path, seat: &str) -> Result<String> {
2408 if is_work_tree(dest) {
2409 ensure_identity(dest, seat)?;
2410 match crate::tracker_push::publish(dest) {
2411 Ok(()) => Ok(format!("tracker: updated {}", dest.display())),
2412 Err(e) => Ok(format!(
2413 "tracker: {} kept local; push queued: {e}",
2414 dest.display()
2415 )),
2416 }
2417 } else if dest.exists() {
2418 let empty = fs::read_dir(dest)
2419 .map(|mut d| d.next().is_none())
2420 .unwrap_or(false);
2421 if !empty {
2422 bail!(
2423 "tracker: {} exists and is not a git checkout",
2424 dest.display()
2425 );
2426 }
2427 fs::remove_dir(dest)?;
2428 clone_tracker(remote, dest, seat)
2429 } else {
2430 clone_tracker(remote, dest, seat)
2431 }
2432}
2433
2434fn clone_tracker(remote: &str, dest: &Path, seat: &str) -> Result<String> {
2435 if let Some(parent) = dest.parent() {
2436 fs::create_dir_all(parent)?;
2437 }
2438 let parent = dest.parent().context("tracker path has no parent")?;
2439 let name = dest
2440 .file_name()
2441 .context("tracker path has no name")?
2442 .to_string_lossy()
2443 .to_string();
2444 let out = Command::new("git")
2445 .current_dir(parent)
2446 .args(["clone", "--quiet", remote, &name])
2447 .env("GIT_TERMINAL_PROMPT", "0")
2448 .stdin(std::process::Stdio::null())
2449 .output()
2450 .context("git clone")?;
2451 if !out.status.success() {
2452 let said = String::from_utf8_lossy(&out.stderr);
2453 bail!("tracker: clone of {remote} failed: {}", said.trim());
2454 }
2455 ensure_identity(dest, seat)?;
2456 match crate::tracker_push::publish(dest) {
2457 Ok(()) => Ok(format!("tracker: cloned {remote} to {}", dest.display())),
2458 Err(e) => Ok(format!(
2459 "tracker: cloned {remote} to {}; push queued: {e}",
2460 dest.display()
2461 )),
2462 }
2463}
2464
2465fn ensure_identity(dir: &Path, seat: &str) -> Result<()> {
2466 let email = git_local(dir, "user.email");
2467 if email.map(|v| v.is_empty()).unwrap_or(true) {
2468 git_set(
2469 dir,
2470 "user.email",
2471 &format!("{seat}@users.noreply.github.com"),
2472 )?;
2473 git_set(dir, "user.name", seat)?;
2474 }
2475 git_set(dir, "commit.gpgsign", "false")?;
2476 git_set(dir, "pull.rebase", "true")?;
2477 Ok(())
2478}
2479
2480fn git_local(dir: &Path, key: &str) -> Option<String> {
2481 let out = Command::new("git")
2482 .arg("-C")
2483 .arg(dir)
2484 .args(["config", "--local", "--get", key])
2485 .stdin(std::process::Stdio::null())
2486 .output()
2487 .ok()?;
2488 if !out.status.success() {
2489 return None;
2490 }
2491 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
2492 (!value.is_empty()).then_some(value)
2493}
2494
2495fn git_set(dir: &Path, key: &str, value: &str) -> Result<()> {
2496 let out = Command::new("git")
2497 .arg("-C")
2498 .arg(dir)
2499 .args(["config", "--local", key, value])
2500 .stdin(std::process::Stdio::null())
2501 .output()
2502 .with_context(|| format!("git config {key}"))?;
2503 if out.status.success() {
2504 Ok(())
2505 } else {
2506 bail!(
2507 "git config {key}: {}",
2508 String::from_utf8_lossy(&out.stderr).trim()
2509 )
2510 }
2511}
2512
2513fn is_work_tree(dir: &Path) -> bool {
2514 let Ok(out) = Command::new("git")
2515 .arg("-C")
2516 .arg(dir)
2517 .args(["rev-parse", "--is-inside-work-tree"])
2518 .stdin(std::process::Stdio::null())
2519 .output()
2520 else {
2521 return false;
2522 };
2523 out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true"
2524}
2525
2526fn write_mcp(
2527 home: &Path,
2528 server: &Path,
2529 plan: &crate::ServerCommandPlan,
2530 seat: &str,
2531 key: &Path,
2532 tracker: Option<&Path>,
2533 remote: Option<&str>,
2534) -> Result<String> {
2535 if !server.is_file() {
2536 bail!(
2537 "mcp: {} does not exist, so the harness was not pointed at it",
2538 server.display()
2539 );
2540 }
2541 let mut env = serde_json::Map::new();
2542 env.insert("LJOS_SEAT".into(), serde_json::json!(seat));
2543 env.insert("VISSUE_AGENT".into(), serde_json::json!(seat));
2544 env.insert(
2545 "LJOS_SEAT_KEY".into(),
2546 serde_json::json!(key.display().to_string()),
2547 );
2548 if std::env::var("DEEDAR_HOST_SIGNING_KEY").ok().as_deref() != Some("off") {
2549 env.insert(
2550 "DEEDAR_HOST_SIGNING_KEY".into(),
2551 serde_json::json!(key.display().to_string()),
2552 );
2553 }
2554 if let Some(tracker) = tracker {
2555 env.insert("LJOS_TRACKER_GIT".into(), serde_json::json!("push"));
2556 env.insert(
2557 "VISSUE_ROOT".into(),
2558 serde_json::json!(tracker.display().to_string()),
2559 );
2560 env.insert(
2561 "ISSUE_ROOT".into(),
2562 serde_json::json!(tracker.display().to_string()),
2563 );
2564 }
2565 if let Some(remote) = remote {
2566 env.insert("LJOS_TRACKER_REMOTE".into(), serde_json::json!(remote));
2567 }
2568 let command = plan.command.as_str();
2569 let entry = serde_json::json!({
2570 "type": "stdio",
2571 "command": command,
2572 "args": [],
2573 "env": env,
2574 });
2575 let path = home.join(".cursor/mcp.json");
2576 crate::set_json_entry(&path, "/mcpServers/ljos", &entry)?;
2577 let mut line = if command == crate::MCP_SERVER_COMMAND {
2578 format!(
2579 "mcp: {} starts {command} on PATH ({})",
2580 path.display(),
2581 server.display()
2582 )
2583 } else {
2584 format!(
2585 "mcp: {} starts {command} ({})",
2586 path.display(),
2587 server.display()
2588 )
2589 };
2590 if let Some(note) = &plan.note {
2591 line.push_str("; ");
2592 line.push_str(note);
2593 }
2594 Ok(line)
2595}
2596
2597fn write_env(path: &Path, pairs: &[(&str, String)]) -> Result<()> {
2598 let text = fs::read_to_string(path).unwrap_or_default();
2599 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
2600 for (key, value) in pairs {
2601 if value.contains('\n') {
2602 bail!("{key} contains a newline");
2603 }
2604 let row = format!("{key}={value}");
2605 if let Some(existing) = lines.iter_mut().find(|line| {
2606 let trimmed = line.trim_start();
2607 trimmed.starts_with(&format!("{key}=")) || trimmed.starts_with(&format!("{key} ="))
2608 }) {
2609 *existing = row;
2610 } else {
2611 lines.push(row);
2612 }
2613 }
2614 let mut out = lines.join("\n");
2615 if !out.ends_with('\n') {
2616 out.push('\n');
2617 }
2618 if let Some(parent) = path.parent() {
2619 fs::create_dir_all(parent)?;
2620 }
2621 fs::write(path, out).with_context(|| format!("write {}", path.display()))?;
2622 Ok(())
2623}
2624
2625struct WakeVar {
2627 name: String,
2628 mid: String,
2629 url: String,
2630}
2631
2632fn ensure_wakes() -> Result<Vec<String>> {
2642 let by_seat = wake_vars_by_seat();
2643 let existing = crate::wake::load();
2644 let mut lines = Vec::new();
2645 for seat in existing.keys() {
2646 let env_owned = existing
2647 .get(seat)
2648 .is_some_and(|wake| wake.source.as_deref() == Some(crate::wake::ENV_SOURCE));
2649 if env_owned && !by_seat.contains_key(seat) && crate::wake::clear_env_webhook(seat)? {
2650 lines.push(format!(
2651 "wake {seat}: removed the stale wake cloud up wrote; no LJOS_WAKE_<SEAT>_URL names it now"
2652 ));
2653 }
2654 }
2655 for (seat, group) in by_seat {
2656 if group.len() > 1 {
2657 lines.push(collision_line(
2658 &seat,
2659 &group,
2660 crate::wake::clear_env_webhook(&seat)?,
2661 ));
2662 continue;
2663 }
2664 let one = &group[0];
2665 if crate::wake::check_webhook(&one.url).is_err() {
2666 let said = if crate::wake::clear_env_webhook(&seat)? {
2667 "removed the stale wake cloud up wrote"
2668 } else {
2669 "no wake was written"
2670 };
2671 lines.push(format!(
2672 "wake {seat}: {} is not https, nor http on this machine, so {said}",
2673 one.name
2674 ));
2675 continue;
2676 }
2677 let key_env = format!("LJOS_WAKE_{}_KEY", one.mid);
2678 let wrote = crate::wake::set_env_webhook(&seat, &one.url, &key_env)?;
2679 if !wrote {
2680 lines.push(format!(
2681 "wake {seat}: left the wake cloud up did not write; {} did not replace it",
2682 one.name
2683 ));
2684 continue;
2685 }
2686 let unset = if nonempty_var(&key_env).is_some() {
2687 ""
2688 } else {
2689 ", which is unset now"
2690 };
2691 lines.push(format!(
2692 "wake {seat}: posts to {} with the key in {key_env}{unset}",
2693 one.name
2694 ));
2695 }
2696 Ok(lines)
2697}
2698
2699fn wake_vars_by_seat() -> BTreeMap<String, Vec<WakeVar>> {
2700 let vars: BTreeMap<String, String> = std::env::vars_os()
2701 .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
2702 .collect();
2703 let mut by_seat: BTreeMap<String, Vec<WakeVar>> = BTreeMap::new();
2704 for (name, value) in vars {
2705 let Some(mid) = name
2706 .strip_prefix("LJOS_WAKE_")
2707 .and_then(|rest| rest.strip_suffix("_URL"))
2708 else {
2709 continue;
2710 };
2711 let url = value.trim();
2712 if url.is_empty() || !mid.bytes().any(|b| b.is_ascii_alphanumeric()) {
2713 continue;
2714 }
2715 let seat = crate::seat_slug(mid);
2716 by_seat.entry(seat).or_default().push(WakeVar {
2717 mid: mid.to_string(),
2718 url: url.to_string(),
2719 name,
2720 });
2721 }
2722 by_seat
2723}
2724
2725fn collision_line(seat: &str, group: &[WakeVar], retired: bool) -> String {
2726 let mut names: Vec<&str> = group.iter().map(|var| var.name.as_str()).collect();
2727 names.sort_unstable();
2728 let said = if retired {
2729 "removed the stale wake cloud up wrote"
2730 } else {
2731 "no wake was written"
2732 };
2733 format!(
2734 "wake {seat}: {} name the same seat, so {said}",
2735 list_names(&names),
2736 )
2737}
2738
2739fn list_names(names: &[&str]) -> String {
2740 let Some((last, rest)) = names.split_last() else {
2741 return String::new();
2742 };
2743 if rest.is_empty() {
2744 return (*last).to_string();
2745 }
2746 let mut line = rest.join(", ");
2747 line.push_str(" and ");
2748 line.push_str(last);
2749 line
2750}
2751
2752fn ensure_profile(home: &Path, bin_dir: &Path) -> Result<String> {
2753 let notes = [
2754 ensure_path_file(&home.join(".profile"), bin_dir, false)?,
2755 ensure_path_file(&home.join(".bashrc"), bin_dir, true)?,
2756 ];
2757 Ok(notes.join("; "))
2758}
2759
2760#[must_use]
2765pub fn cloud_seat() -> bool {
2766 cloud_guard_rules_path().is_some_and(|path| covers_seat(&crate::kept_rules(&path)))
2767}
2768
2769#[must_use]
2773pub fn any_cloud_marker() -> bool {
2774 [cloud_guard_rules_path(), crate::cloud_hook::allow_path()]
2775 .into_iter()
2776 .flatten()
2777 .any(|path| path.symlink_metadata().is_ok())
2778}
2779
2780pub fn replace_stale_guard() {
2784 let Some(path) = cloud_guard_rules_path() else {
2785 return;
2786 };
2787 if !path.is_file() {
2788 return;
2789 }
2790 let have = crate::kept_rules(&path);
2791 if !covers_seat(&have) || current_rules(&have) {
2792 return;
2793 }
2794 crate::keep_rules(&path, &cloud_guard_rules());
2795}
2796
2797#[derive(Clone, Copy, PartialEq, Eq)]
2799enum SeatPart {
2800 Push,
2801 Open,
2802 Merge,
2803}
2804
2805fn seat_part(pattern: &str) -> Option<SeatPart> {
2807 match pattern {
2808 CURSOR_PUSH | LEGACY_CURSOR_PUSH => Some(SeatPart::Push),
2809 GH_PR_CREATE | LEGACY_GH_PR_CREATE => Some(SeatPart::Open),
2810 GH_PR_MERGE | LEGACY_GH_PR_MERGE => Some(SeatPart::Merge),
2811 _ => None,
2812 }
2813}
2814
2815fn covers_seat(rules: &[crate::Rule]) -> bool {
2818 if rules.is_empty() {
2819 return false;
2820 }
2821 let mut push = false;
2822 let mut open = false;
2823 let mut merge = false;
2824 for rule in rules {
2825 if rule.verdict != "deny" {
2826 return false;
2827 }
2828 match seat_part(&rule.pattern) {
2829 Some(SeatPart::Push) => push = true,
2830 Some(SeatPart::Open) => open = true,
2831 Some(SeatPart::Merge) => merge = true,
2832 None => return false,
2833 }
2834 }
2835 push && open && merge
2836}
2837
2838fn current_rules(rules: &[crate::Rule]) -> bool {
2841 if rules.len() != 3 {
2842 return false;
2843 }
2844 let mut seen = [false; 3];
2845 for rule in rules {
2846 if rule.verdict != "deny" {
2847 return false;
2848 }
2849 let index = match rule.pattern.as_str() {
2850 CURSOR_PUSH => 0,
2851 GH_PR_CREATE => 1,
2852 GH_PR_MERGE => 2,
2853 _ => return false,
2854 };
2855 if seen[index] {
2856 return false;
2857 }
2858 seen[index] = true;
2859 }
2860 seen == [true, true, true]
2861}
2862
2863pub fn write_push_allowlist(code: &[&str], tracker: &[&str], branches: &[&str]) -> Result<()> {
2870 crate::cloud_hook::write_push_allowlist(code, tracker, branches)
2871}
2872
2873pub(crate) fn ensure_cloud_guard_rules() -> Result<String> {
2874 let path =
2875 cloud_guard_rules_path().context("cloud up: no config home for cloud guard rules")?;
2876 let wanted = cloud_guard_rules();
2877 let have = crate::kept_rules(&path);
2878 if have == wanted {
2879 return Ok(format!(
2880 "guard: {} already has cloud seat refusals",
2881 path.display()
2882 ));
2883 }
2884 crate::keep_rules(&path, &wanted);
2885 let wrote = crate::kept_rules(&path);
2886 if wrote != wanted {
2887 bail!(
2888 "cloud up: could not write {} with the cloud seat refusals",
2889 path.display()
2890 );
2891 }
2892 Ok(format!(
2893 "guard: {} now denies cursor/* pushes and `gh pr create|merge`",
2894 path.display()
2895 ))
2896}
2897
2898fn cloud_guard_rules_path() -> Option<PathBuf> {
2899 Some(
2900 crate::config_base()?
2901 .join("ljos")
2902 .join(CLOUD_GUARD_RULES_FILE),
2903 )
2904}
2905
2906fn cloud_guard_rules() -> Vec<crate::Rule> {
2907 vec![
2908 crate::Rule {
2909 pattern: CURSOR_PUSH.into(),
2910 verdict: "deny".into(),
2911 reason:
2912 "cloud seat rule: outside the seat allowlist, or the pre-push hook would not run"
2913 .into(),
2914 },
2915 crate::Rule {
2916 pattern: GH_PR_CREATE.into(),
2917 verdict: "deny".into(),
2918 reason: "cloud seat rule: never open pull requests from this machine".into(),
2919 },
2920 crate::Rule {
2921 pattern: GH_PR_MERGE.into(),
2922 verdict: "deny".into(),
2923 reason: "cloud seat rule: never merge pull requests from this machine".into(),
2924 },
2925 ]
2926}
2927
2928enum NamedSecret {
2932 Unset,
2933 Bad(&'static str),
2934 Name(String),
2935}
2936
2937fn named_secret() -> NamedSecret {
2938 let Ok(raw) = std::env::var(CLOUD_AGE_SECRET) else {
2939 return NamedSecret::Unset;
2940 };
2941 let name = raw.trim();
2942 if name.is_empty() {
2943 return NamedSecret::Unset;
2944 }
2945 if raw.contains("AGE-SECRET-KEY-") || raw.contains('\n') || raw.contains('\0') {
2946 return NamedSecret::Bad("holds an age identity; set it to the variable's name");
2947 }
2948 if name == CLOUD_AGE_SECRET {
2949 return NamedSecret::Bad("cannot name itself");
2950 }
2951 if !valid_env_name(name) {
2952 return NamedSecret::Bad("is not an environment variable name");
2953 }
2954 NamedSecret::Name(name.to_string())
2955}
2956
2957fn valid_env_name(name: &str) -> bool {
2958 let mut chars = name.chars();
2959 match chars.next() {
2960 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
2961 _ => return false,
2962 }
2963 name.len() <= 128 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2964}
2965
2966enum CloudAge {
2967 Missing,
2968 Good,
2969 BadMode(u32),
2970 NotIdentity,
2971 Symlink,
2972 Unreadable,
2973}
2974
2975fn inspect_cloud_age(path: &Path) -> CloudAge {
2976 let meta = match fs::symlink_metadata(path) {
2977 Ok(meta) => meta,
2978 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return CloudAge::Missing,
2979 Err(_) => return CloudAge::Unreadable,
2980 };
2981 if meta.file_type().is_symlink() {
2982 return CloudAge::Symlink;
2983 }
2984 if !meta.is_file() || meta.len() > CLOUD_AGE_MAX as u64 {
2985 return CloudAge::NotIdentity;
2986 }
2987 let text = match fs::read_to_string(path) {
2988 Ok(text) => text,
2989 Err(_) => return CloudAge::Unreadable,
2990 };
2991 if !crate::sync::age_identity(&text) {
2992 return CloudAge::NotIdentity;
2993 }
2994 use std::os::unix::fs::PermissionsExt;
2995 let mode = meta.permissions().mode() & 0o777;
2996 if mode == 0o600 {
2997 CloudAge::Good
2998 } else {
2999 CloudAge::BadMode(mode)
3000 }
3001}
3002
3003fn missing_cloud_age(path: &Path) -> crate::Habitat {
3004 match named_secret() {
3005 NamedSecret::Unset => crate::Habitat {
3006 name: "cloud age",
3007 state: format!(
3008 "unset; no identity for scope cloud. Set {CLOUD_AGE_SECRET} to the secret's name and run `ljos cloud up`"
3009 ),
3010 ok: true,
3011 },
3012 NamedSecret::Bad(why) => crate::Habitat {
3013 name: "cloud age",
3014 state: format!("{CLOUD_AGE_SECRET} {why}"),
3015 ok: false,
3016 },
3017 NamedSecret::Name(name) => {
3018 let problem = secret_problem(&name);
3019 let state = match problem {
3020 Some(problem) => format!("{name} {problem}, and {} is missing", path.display()),
3021 None => format!(
3022 "{name} is set, and {} is missing. Run `ljos cloud up`",
3023 path.display()
3024 ),
3025 };
3026 crate::Habitat {
3027 name: "cloud age",
3028 state,
3029 ok: false,
3030 }
3031 }
3032 }
3033}
3034
3035fn secret_problem(name: &str) -> Option<&'static str> {
3038 match std::env::var(name) {
3039 Err(std::env::VarError::NotPresent) => Some("is unset"),
3040 Err(std::env::VarError::NotUnicode(_)) => Some("is not text"),
3041 Ok(value) if value.trim().is_empty() => Some("is empty"),
3042 Ok(value) if value.len() > CLOUD_AGE_MAX => Some("is not an age identity"),
3043 Ok(value) if !crate::sync::age_identity(&normalize_identity(&value)) => {
3044 Some("is not an age identity")
3045 }
3046 Ok(_) => None,
3047 }
3048}
3049
3050fn normalize_identity(text: &str) -> String {
3051 let text = text.replace("\r\n", "\n").replace('\r', "\n");
3052 let mut out = String::new();
3053 for line in text.trim().lines() {
3054 out.push_str(line.trim_end());
3055 out.push('\n');
3056 }
3057 out
3058}
3059
3060fn ensure_cloud_age() -> Result<String> {
3061 let name = match named_secret() {
3062 NamedSecret::Unset => {
3063 return Ok(format!(
3064 "cloud age: {CLOUD_AGE_SECRET} is unset, so scope cloud has no identity on this machine"
3065 ));
3066 }
3067 NamedSecret::Bad(why) => bail!("cloud up: {CLOUD_AGE_SECRET} {why}"),
3068 NamedSecret::Name(name) => name,
3069 };
3070 let value = match std::env::var(&name) {
3071 Err(std::env::VarError::NotPresent) => {
3072 bail!("cloud up: {name} is unset, so the scope cloud identity was not written")
3073 }
3074 Err(std::env::VarError::NotUnicode(_)) => {
3075 bail!("cloud up: {name} is not text, so the scope cloud identity was not written")
3076 }
3077 Ok(value) => value,
3078 };
3079 if value.trim().is_empty() {
3080 bail!("cloud up: {name} is empty, so the scope cloud identity was not written");
3081 }
3082 if value.len() > CLOUD_AGE_MAX {
3083 bail!("cloud up: {name} is too long to be an age identity");
3084 }
3085 let text = normalize_identity(&value);
3086 if !crate::sync::age_identity(&text) {
3087 bail!(
3088 "cloud up: {name} is not an age identity, so the scope cloud identity was not written"
3089 );
3090 }
3091 let path = crate::sync::cloud_identity_path();
3092 match install_identity(&path, &text)? {
3093 Install::Wrote => Ok(format!("cloud age: wrote {} mode 0600", path.display())),
3094 Install::Mode => Ok(format!("cloud age: set {} to mode 0600", path.display())),
3095 Install::Same => Ok(format!(
3096 "cloud age: {} already holds the scope cloud identity (0600)",
3097 path.display()
3098 )),
3099 }
3100}
3101
3102enum Install {
3103 Wrote,
3104 Mode,
3105 Same,
3106}
3107
3108fn install_identity(path: &Path, text: &str) -> Result<Install> {
3109 refuse_symlink_parent(path)?;
3113 let regular = fs::symlink_metadata(path)
3117 .ok()
3118 .is_some_and(|meta| meta.file_type().is_file());
3119 if regular && fs::read_to_string(path).ok().as_deref() == Some(text) {
3120 if file_mode(path) == Some(0o600) {
3121 return Ok(Install::Same);
3122 }
3123 set_mode_0600(path)?;
3124 return Ok(Install::Mode);
3125 }
3126 write_private(path, text)?;
3127 Ok(Install::Wrote)
3128}
3129
3130fn file_mode(path: &Path) -> Option<u32> {
3131 use std::os::unix::fs::PermissionsExt;
3132 fs::symlink_metadata(path)
3133 .ok()
3134 .filter(|meta| meta.file_type().is_file())
3135 .map(|meta| meta.permissions().mode() & 0o777)
3136}
3137
3138fn ancestor_is_symlink(path: &Path) -> bool {
3141 let Some(parent) = path.parent() else {
3142 return false;
3143 };
3144 let mut walked = PathBuf::new();
3145 for component in parent.components() {
3146 match component {
3147 std::path::Component::RootDir => walked.push(component),
3148 std::path::Component::CurDir => {}
3149 std::path::Component::Normal(name) => {
3150 walked.push(name);
3151 if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
3152 return true;
3153 }
3154 }
3155 _ => return true,
3156 }
3157 }
3158 false
3159}
3160
3161fn refuse_symlink_parent(path: &Path) -> Result<()> {
3162 if ancestor_is_symlink(path) {
3163 bail!(
3164 "cloud up: {} is reached through a symlink, so the scope cloud identity was not written",
3165 path.display()
3166 );
3167 }
3168 Ok(())
3169}
3170
3171fn open_nofollow(path: &Path) -> std::io::Result<fs::File> {
3174 use std::os::fd::{AsRawFd, FromRawFd};
3175 use std::os::unix::ffi::OsStrExt;
3176 use std::os::unix::fs::OpenOptionsExt;
3177 let absolute;
3178 let path = if path.is_absolute() {
3179 path
3180 } else {
3181 absolute = std::env::current_dir()?.join(path);
3182 absolute.as_path()
3183 };
3184 let mut names = Vec::new();
3185 for component in path.components() {
3186 match component {
3187 std::path::Component::RootDir | std::path::Component::CurDir => {}
3188 std::path::Component::Normal(name) => names.push(name),
3189 _ => {
3190 return Err(std::io::Error::new(
3191 std::io::ErrorKind::InvalidInput,
3192 "path is not direct",
3193 ));
3194 }
3195 }
3196 }
3197 let mut current = fs::OpenOptions::new()
3198 .read(true)
3199 .custom_flags(libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
3200 .open("/")?;
3201 for (index, name) in names.iter().enumerate() {
3202 let last = index + 1 == names.len();
3203 let mut bytes = name.as_bytes().to_vec();
3204 if bytes.contains(&0) {
3205 return Err(std::io::Error::new(
3206 std::io::ErrorKind::InvalidInput,
3207 "nul in path",
3208 ));
3209 }
3210 bytes.push(0);
3211 let mut flags = libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_RDONLY;
3212 if !last {
3213 flags |= libc::O_DIRECTORY;
3214 }
3215 let fd = unsafe { libc::openat(current.as_raw_fd(), bytes.as_ptr().cast(), flags) };
3218 if fd < 0 {
3219 return Err(std::io::Error::last_os_error());
3220 }
3221 current = unsafe { fs::File::from_raw_fd(fd) };
3223 }
3224 Ok(current)
3225}
3226
3227fn set_mode_0600(path: &Path) -> Result<()> {
3228 use std::os::unix::fs::PermissionsExt;
3229 let linked = ancestor_is_symlink(path)
3232 || fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink());
3233 if linked {
3234 bail!("mode 0600 {}: path is a symlink", path.display());
3235 }
3236 let file = match open_nofollow(path) {
3237 Ok(file) => file,
3238 Err(err) if matches!(err.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR)) => {
3239 bail!("mode 0600 {}: path is a symlink", path.display());
3240 }
3241 Err(err) => {
3242 return Err(err).with_context(|| format!("mode 0600 {}", path.display()));
3243 }
3244 };
3245 if !file.metadata().is_ok_and(|meta| meta.file_type().is_file()) {
3246 bail!("mode 0600 {}: target is not a file", path.display());
3247 }
3248 file.set_permissions(fs::Permissions::from_mode(0o600))
3249 .with_context(|| format!("mode 0600 {}", path.display()))
3250}
3251
3252fn write_private(path: &Path, text: &str) -> Result<()> {
3258 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
3259 refuse_symlink_parent(path)?;
3260 if let Some(dir) = path.parent() {
3261 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
3262 }
3263 if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
3264 bail!(
3265 "cloud up: {} is a symlink, so the scope cloud identity was not written; remove it and run `ljos cloud up` again",
3266 path.display()
3267 );
3268 }
3269 let name = path
3270 .file_name()
3271 .and_then(|n| n.to_str())
3272 .unwrap_or("cloud.age");
3273 let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
3274 let mut file = fs::OpenOptions::new()
3275 .write(true)
3276 .create_new(true)
3277 .mode(0o600)
3278 .custom_flags(libc::O_NOFOLLOW)
3279 .open(&tmp)
3280 .with_context(|| format!("create {}", tmp.display()))?;
3281 let write = (|| -> Result<()> {
3282 file.set_permissions(fs::Permissions::from_mode(0o600))?;
3284 file.write_all(text.as_bytes())?;
3285 file.sync_all()?;
3286 fs::rename(&tmp, path).with_context(|| format!("install {}", path.display()))?;
3287 Ok(())
3288 })();
3289 if write.is_err() {
3290 let _ = fs::remove_file(&tmp);
3291 }
3292 write
3293}
3294
3295fn path_export_line(bin_dir: &Path) -> String {
3296 format!("export PATH=\"{}:$PATH\"", bin_dir.display())
3297}
3298
3299fn first_interactive_guard(text: &str) -> Option<usize> {
3300 let lines: Vec<&str> = text.split_inclusive('\n').collect();
3301 let mut at = 0usize;
3302 for (i, line) in lines.iter().enumerate() {
3303 let trimmed = line.trim();
3304 let one_line =
3305 (trimmed.contains("$PS1") || trimmed.contains("$-")) && trimmed.contains("return");
3306 let rest = &lines[i + 1..];
3312 if one_line
3313 || (case_tests_shell(trimmed) && block_returns(rest, "esac"))
3314 || (if_tests_shell(trimmed) && block_returns(rest, "fi"))
3315 {
3316 return Some(at);
3317 }
3318 at += line.len();
3319 }
3320 None
3321}
3322
3323fn case_tests_shell(trimmed: &str) -> bool {
3325 let Some(rest) = trimmed.strip_prefix("case") else {
3326 return false;
3327 };
3328 let rest = rest.trim_start();
3329 let word = rest.split_whitespace().next().unwrap_or("");
3330 let word = word.trim_matches(|c| c == '"' || c == '\'');
3331 word == "$-" || word == "$PS1"
3332}
3333
3334fn if_tests_shell(trimmed: &str) -> bool {
3337 trimmed.starts_with("if ") && (trimmed.contains("$PS1") || trimmed.contains("$-"))
3338}
3339
3340fn block_returns(lines: &[&str], end: &str) -> bool {
3343 for line in lines {
3344 let trimmed = line.trim();
3345 let first = trimmed.split([';', ' ', '\t']).next().unwrap_or("");
3346 if first == end {
3347 return false;
3348 }
3349 if trimmed.contains("return") {
3350 return true;
3351 }
3352 }
3353 false
3354}
3355
3356fn ensure_path_file(path: &Path, bin_dir: &Path, before_interactive_guard: bool) -> Result<String> {
3357 let have = fs::read_to_string(path).unwrap_or_default();
3358 let line = path_export_line(bin_dir);
3359 if have.lines().any(|l| l.trim() == line) {
3360 return Ok(format!(
3361 "path: {} already names {}",
3362 path.display(),
3363 bin_dir.display()
3364 ));
3365 }
3366 let mut out = have;
3367 if before_interactive_guard {
3368 if let Some(at) = first_interactive_guard(&out) {
3369 let mut next = String::with_capacity(out.len() + line.len() + 1);
3370 next.push_str(&out[..at]);
3371 next.push_str(&line);
3372 next.push('\n');
3373 next.push_str(&out[at..]);
3374 out = next;
3375 } else {
3376 if !out.is_empty() && !out.ends_with('\n') {
3377 out.push('\n');
3378 }
3379 out.push_str(&line);
3380 out.push('\n');
3381 }
3382 } else {
3383 if !out.is_empty() && !out.ends_with('\n') {
3384 out.push('\n');
3385 }
3386 out.push_str(&line);
3387 out.push('\n');
3388 }
3389 if let Some(parent) = path.parent() {
3390 fs::create_dir_all(parent)?;
3391 }
3392 fs::write(path, out).with_context(|| format!("write {}", path.display()))?;
3393 Ok(format!(
3394 "path: {} adds {}",
3395 path.display(),
3396 bin_dir.display()
3397 ))
3398}
3399
3400fn ensure_process_path(bin_dir: &Path) -> String {
3403 let dir = bin_dir.display().to_string();
3404 let current = std::env::var("PATH").unwrap_or_default();
3405 let entries: Vec<&str> = current
3406 .split(':')
3407 .filter(|entry| !entry.is_empty())
3408 .collect();
3409 if entries.first() == Some(&dir.as_str()) {
3410 return format!("path: PATH already starts with {dir}");
3411 }
3412 let mut next = dir.clone();
3413 for entry in entries.into_iter().filter(|entry| *entry != dir) {
3414 next.push(':');
3415 next.push_str(entry);
3416 }
3417 crate::with_env_mutation(|| std::env::set_var("PATH", &next));
3418 format!("path: PATH starts with {dir}")
3419}
3420
3421fn release_started(id: &str, holder: &str) -> Result<Option<String>, String> {
3422 let layout = vissue_core::Layout::resolve(None, None).map_err(|e| e.to_string())?;
3423 let found = vissue_core::store::find_by_id(&layout, id).map_err(|e| e.to_string())?;
3424 let Some((_, path, project)) = found else {
3425 return Ok(None);
3426 };
3427 vissue_core::store::with_issues_lock(&path, || {
3428 let mut doc = vissue_core::IssueDoc::parse_file(&project, &path)?;
3429 let Some(heading) = doc.headings.iter_mut().find(|h| h.id == id) else {
3430 return Ok(None);
3431 };
3432 let Some(who) = heading.claimed_by().map(str::to_string) else {
3433 return Ok(None);
3434 };
3435 if !crate::same_holder(&who, holder) {
3436 return Ok(None);
3437 }
3438 heading.release_claim();
3439 if heading.state == "STARTED" {
3440 heading.record_state_change("TODO");
3441 }
3442 doc.write()?;
3443 Ok(Some(format!(
3444 "tracker: {id} no longer claimed by {who}; STARTED handed back"
3445 )))
3446 })
3447 .map_err(|e| e.to_string())
3448}
3449
3450fn holder_running(assignee: &str, issue: &str) -> bool {
3451 let Ok(entries) = fs::read_dir(crate::runtime_dir()) else {
3452 return false;
3453 };
3454 for ent in entries.flatten() {
3455 let name = ent.file_name().to_string_lossy().into_owned();
3456 let Some(actor) = name.strip_prefix("hold-") else {
3457 continue;
3458 };
3459 if !hold_names_claim(actor, assignee, issue) {
3460 continue;
3461 }
3462 if let Some(hold) = crate::read_hold(actor) {
3463 if crate::hold_alive(&hold) {
3464 return true;
3465 }
3466 }
3467 }
3468 false
3469}
3470
3471fn hold_names_claim(actor: &str, assignee: &str, issue: &str) -> bool {
3479 let path = crate::runtime_dir().join(format!("hold-{actor}"));
3480 let Ok(text) = fs::read_to_string(path) else {
3481 return false;
3482 };
3483 let lines: Vec<&str> = text.lines().map(str::trim).collect();
3484 let record_assignee = lines.first().copied().unwrap_or("");
3485 let node = lines.get(5).copied().unwrap_or("");
3486 let name_ok =
3487 crate::same_holder(actor, assignee) || crate::same_holder(record_assignee, assignee);
3488 if !name_ok {
3489 return false;
3490 }
3491 if node.is_empty() {
3492 return crate::same_holder(actor, assignee);
3493 }
3494 node == issue
3495}
3496
3497fn bin_dir(home: &Path) -> PathBuf {
3498 std::env::var_os("LJOS_BIN_DIR")
3499 .filter(|p| !p.is_empty())
3500 .map(PathBuf::from)
3501 .unwrap_or_else(|| home.join(".local/bin"))
3502}
3503
3504fn tracker_dir(home: &Path) -> PathBuf {
3505 std::env::var_os("LJOS_TRACKER_DIR")
3506 .filter(|p| !p.is_empty())
3507 .map(PathBuf::from)
3508 .unwrap_or_else(|| home.join(".local/share/ljos/tracker"))
3509}
3510
3511fn seat_matches_file() -> bool {
3512 let Some(file) = env_file_value("LJOS_SEAT") else {
3513 return false;
3514 };
3515 std::env::var("LJOS_SEAT").ok().as_deref() == Some(file.as_str())
3516}
3517
3518fn env_file_value(key: &str) -> Option<String> {
3519 let home = crate::home().ok()?;
3520 let text = fs::read_to_string(home.join(".config/ljos/env")).ok()?;
3521 for line in text.lines() {
3522 let line = line.trim();
3523 let Some((k, v)) = line.split_once('=') else {
3524 continue;
3525 };
3526 if k.trim() == key {
3527 return Some(v.trim().to_string());
3528 }
3529 }
3530 None
3531}
3532
3533fn cursor_mcp_advice() -> String {
3537 if on_cloud_vm() {
3538 "ljos cloud up".into()
3539 } else {
3540 "ljos onboard --harness cursor".into()
3541 }
3542}
3543
3544fn on_cloud_vm() -> bool {
3545 let rt = runtime();
3546 if agent_id().is_some() || rt.ci || rt.codespace {
3547 return true;
3548 }
3549 if rt.cursor_harness && rt.cloud_agent {
3552 return true;
3553 }
3554 cloud_env_file_seat()
3555}
3556
3557fn runtime() -> Runtime {
3561 let github_actions = env_is_true("GITHUB_ACTIONS");
3562 let cloud_id =
3563 nonempty_var("CURSOR_AGENT_ID").is_some() || nonempty_var("CLOUD_AGENT_ID").is_some();
3564 let api_socket = Path::new("/run/cursor/api.sock").exists();
3565 let cloud_agent = cloud_id
3568 || nonempty_var("CURSOR_AGENT_SOCKET").is_some()
3569 || nonempty_var("CURSOR_CONVERSATION_ID").is_some();
3570 let cursor_harness = cloud_agent
3571 || api_socket
3572 || std::env::var_os("CURSOR_AGENT").is_some()
3573 || std::env::var_os("CURSOR_AGENT_ID").is_some();
3574 let codespace = env_is_true("CODESPACES") || nonempty_var("CODESPACE_NAME").is_some();
3575 let ci = github_actions
3580 || env_is_true("GITLAB_CI")
3581 || env_is_true("CI")
3582 || env_is_true("BUILDKITE")
3583 || env_is_true("TF_BUILD")
3584 || env_is_true("CIRCLECI")
3585 || nonempty_var("JENKINS_URL").is_some();
3586 Runtime {
3587 github_actions,
3588 ci,
3589 codespace,
3590 cloud_id,
3591 api_socket,
3592 cursor_harness,
3593 cloud_agent,
3594 }
3595}
3596
3597struct Runtime {
3598 github_actions: bool,
3599 ci: bool,
3600 codespace: bool,
3601 cloud_id: bool,
3602 api_socket: bool,
3603 cursor_harness: bool,
3604 cloud_agent: bool,
3605}
3606
3607fn env_is_true(key: &str) -> bool {
3609 std::env::var(key)
3610 .ok()
3611 .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
3612}
3613
3614fn cloud_env_file_seat() -> bool {
3617 let Some(seat) = env_file_value("LJOS_SEAT") else {
3618 return false;
3619 };
3620 ["cursor-", "ci-", "codespace-"]
3621 .iter()
3622 .any(|prefix| seat.starts_with(prefix))
3623}
3624
3625fn nonempty_var(key: &str) -> Option<String> {
3626 std::env::var(key)
3627 .ok()
3628 .map(|v| v.trim().to_string())
3629 .filter(|v| !v.is_empty())
3630}
3631
3632fn agent_id() -> Option<String> {
3633 if github_actions() {
3636 if let Some(id) = id_slug("GITHUB_RUN_ID") {
3637 return Some(id);
3638 }
3639 }
3640 for key in [
3641 "CURSOR_AGENT_ID",
3642 "CURSOR_AGENT",
3643 "CLOUD_AGENT_ID",
3644 "AGENT_ID",
3645 "CURSOR_CONVERSATION_ID",
3646 "CODESPACE_NAME",
3647 "GITHUB_RUN_ID",
3648 ] {
3649 if let Some(id) = id_slug(key) {
3650 return Some(id);
3651 }
3652 }
3653 None
3654}
3655
3656fn github_actions() -> bool {
3657 runtime().github_actions
3658}
3659
3660fn id_slug(key: &str) -> Option<String> {
3661 run_slug(&nonempty_var(key)?)
3662}
3663
3664fn run_slug(id: &str) -> Option<String> {
3665 let id = id.trim();
3666 if matches!(id, "0" | "1" | "true" | "false") {
3667 return None;
3668 }
3669 let slug = crate::seat_slug(id);
3670 if slug.is_empty() || slug == "runner" {
3671 return None;
3672 }
3673 Some(slug.chars().take(16).collect())
3674}
3675
3676fn harness_kind() -> &'static str {
3677 let rt = runtime();
3678 if rt.cursor_harness {
3679 "cursor"
3680 } else if rt.codespace {
3681 "codespace"
3682 } else if rt.ci {
3683 "ci"
3684 } else {
3685 "vm"
3686 }
3687}
3688
3689fn hostname() -> String {
3690 fs::read_to_string("/etc/hostname")
3691 .ok()
3692 .map(|s| s.trim().to_string())
3693 .filter(|s| !s.is_empty())
3694 .unwrap_or_else(|| "vm".into())
3695}
3696
3697#[derive(Debug)]
3698struct ArchiveMember {
3699 kind: u8,
3701 name: Vec<u8>,
3702}
3703
3704fn refuse_unsafe_archive(file: &Path) -> Result<()> {
3707 let out = Command::new("tar")
3708 .args(["--full-time", "--quoting-style=shell-always", "-tvzf"])
3709 .arg(file)
3710 .stdin(std::process::Stdio::null())
3711 .env("LC_ALL", "C")
3712 .output()
3713 .context("tar")?;
3714 if !out.status.success() {
3715 bail!(
3716 "tar {}: {}; nothing was installed",
3717 file.display(),
3718 String::from_utf8_lossy(&out.stderr).trim()
3719 );
3720 }
3721 let members = match parse_tar_listing(&out.stdout) {
3722 Ok(members) => members,
3723 Err(err) => bail!("tar {}: {err}; nothing was installed", file.display()),
3724 };
3725 for member in members {
3726 let shown = String::from_utf8_lossy(&member.name);
3727 if member.name.is_empty() || member.name.contains(&0) {
3728 bail!(
3729 "encoder archive {}: a member path is empty; nothing was installed",
3730 file.display()
3731 );
3732 }
3733 if !archive_member_path_ok(&member.name) {
3734 bail!(
3735 "encoder archive {}: member {shown} has an absolute path or '..'; nothing was installed",
3736 file.display()
3737 );
3738 }
3739 match member.kind {
3740 b'-' | b'd' => {}
3741 b'l' => bail!(
3742 "encoder archive {}: member {shown} is a symlink; nothing was installed",
3743 file.display()
3744 ),
3745 b'h' => bail!(
3746 "encoder archive {}: member {shown} is a hard link; nothing was installed",
3747 file.display()
3748 ),
3749 _ => bail!(
3750 "encoder archive {}: member {shown} is not a regular file; nothing was installed",
3751 file.display()
3752 ),
3753 }
3754 }
3755 Ok(())
3756}
3757
3758fn archive_member_path_ok(name: &[u8]) -> bool {
3759 use std::os::unix::ffi::OsStrExt;
3760 use std::path::Component;
3761 if name.is_empty() || name.contains(&0) || name[0] == b'/' {
3762 return false;
3763 }
3764 let path = Path::new(std::ffi::OsStr::from_bytes(name));
3765 if path.is_absolute() {
3766 return false;
3767 }
3768 path.components()
3769 .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
3770}
3771
3772fn parse_tar_listing(bytes: &[u8]) -> Result<Vec<ArchiveMember>> {
3775 let mut members = Vec::new();
3776 let mut i = 0;
3777 while i < bytes.len() {
3778 if bytes[i] == b'\n' {
3779 i += 1;
3780 continue;
3781 }
3782 let kind = bytes[i];
3783 let quote = bytes[i..]
3784 .iter()
3785 .position(|b| *b == b'\'')
3786 .context("unquoted tar member")?;
3787 let start = i + quote;
3788 let (name, used) = parse_shell_single(&bytes[start..])?;
3789 let mut j = start + used;
3790 let rest = &bytes[j..];
3791 if rest.starts_with(b" -> ") || rest.starts_with(b" link to ") {
3792 let target = rest
3793 .iter()
3794 .position(|b| *b == b'\'')
3795 .context("unquoted link target")?;
3796 let (_, used_target) = parse_shell_single(&rest[target..])?;
3797 j += target + used_target;
3798 }
3799 if j < bytes.len() && bytes[j] == b'\n' {
3800 j += 1;
3801 } else if j < bytes.len() {
3802 bail!("tar member list has trailing bytes");
3803 }
3804 members.push(ArchiveMember { kind, name });
3805 i = j;
3806 }
3807 Ok(members)
3808}
3809
3810fn parse_shell_single(bytes: &[u8]) -> Result<(Vec<u8>, usize)> {
3812 if bytes.first() != Some(&b'\'') {
3813 bail!("tar member is not shell-quoted");
3814 }
3815 let mut out = Vec::new();
3816 let mut i = 1;
3817 while i < bytes.len() {
3818 if bytes[i] == b'\'' {
3819 i += 1;
3820 if bytes.get(i) == Some(&b'\\') && bytes.get(i + 1) == Some(&b'\'') {
3821 out.push(b'\'');
3822 i += 2;
3823 if bytes.get(i) != Some(&b'\'') {
3824 bail!("broken shell quote in tar member");
3825 }
3826 i += 1;
3827 continue;
3828 }
3829 return Ok((out, i));
3830 }
3831 out.push(bytes[i]);
3832 i += 1;
3833 }
3834 bail!("unterminated shell quote in tar member")
3835}
3836
3837fn refuse_unsafe_tree(root: &Path) -> Result<()> {
3840 use std::os::unix::fs::MetadataExt;
3841 let mut pending = vec![root.to_path_buf()];
3842 while let Some(dir) = pending.pop() {
3843 for ent in fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
3844 let path = ent?.path();
3845 let meta =
3846 fs::symlink_metadata(&path).with_context(|| format!("stat {}", path.display()))?;
3847 let rel = path.strip_prefix(root).unwrap_or(path.as_path());
3848 let ft = meta.file_type();
3849 if ft.is_symlink() {
3850 bail!(
3851 "encoder archive member {} is a symlink; nothing was installed",
3852 rel.display()
3853 );
3854 }
3855 if ft.is_dir() {
3856 pending.push(path);
3857 continue;
3858 }
3859 if !ft.is_file() {
3860 bail!(
3861 "encoder archive member {} is not a regular file; nothing was installed",
3862 rel.display()
3863 );
3864 }
3865 if meta.nlink() != 1 {
3866 bail!(
3867 "encoder archive member {} is a hard link; nothing was installed",
3868 rel.display()
3869 );
3870 }
3871 }
3872 }
3873 Ok(())
3874}
3875
3876fn regular_file(path: &Path) -> bool {
3877 fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file())
3878}
3879
3880#[cfg(all(test, unix))]
3881mod archive_member_tests {
3882 use super::{archive_member_path_ok, parse_tar_listing, refuse_unsafe_archive};
3883
3884 #[test]
3885 fn shell_quotes_and_escaping_paths_parse() {
3886 let mut raw = b"-rw-r--r-- 0/0 3 1970-01-01 00:00:00 'bin/odd '".to_vec();
3887 raw.extend_from_slice(b"\\''name'\\''\nwith space'\n");
3888 raw.extend_from_slice(
3889 b"lrwxrwxrwx ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/packset-embed' -> '/etc/passwd'\n",
3890 );
3891 raw.extend_from_slice(
3892 b"hrw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/real' link to 'bin/packset-embed'\n",
3893 );
3894 raw.extend_from_slice(b"prw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/fifo'\n");
3895 raw.extend_from_slice(b"-rw-r--r-- 0/0 17 1970-01-01 00:00:00 '/bin/packset-embed'\n");
3896 raw.extend_from_slice(b"-rw-r--r-- 0/0 7 1970-01-01 00:00:00 '../escaped'\n");
3897 let members = parse_tar_listing(&raw).unwrap();
3898 assert_eq!(members.len(), 6, "{members:?}");
3899 assert_eq!(members[0].kind, b'-');
3900 assert_eq!(members[0].name, b"bin/odd 'name'\nwith space");
3901 assert_eq!(members[1].kind, b'l');
3902 assert_eq!(members[2].kind, b'h');
3903 assert_eq!(members[3].kind, b'p');
3904 assert_eq!(members[4].name, b"/bin/packset-embed");
3905 assert_eq!(members[5].name, b"../escaped");
3906 assert!(archive_member_path_ok(&members[0].name));
3907 assert!(!archive_member_path_ok(&members[4].name));
3908 assert!(!archive_member_path_ok(&members[5].name));
3909 assert!(!archive_member_path_ok(b"bin/../../escaped2"));
3910 assert!(archive_member_path_ok(
3911 b"lib/onnxruntime/libonnxruntime.so.1"
3912 ));
3913 assert!(archive_member_path_ok(b"lib/"));
3914 }
3915
3916 #[test]
3917 fn tar_listing_refuses_a_symlink_member() {
3918 let root = tempfile::tempdir().unwrap();
3919 let dir = root.path().join("tree");
3920 std::fs::create_dir_all(dir.join("bin")).unwrap();
3921 std::os::unix::fs::symlink("/etc/passwd", dir.join("bin/packset-embed")).unwrap();
3922 let archive = root.path().join("bad.tar.gz");
3923 let out = std::process::Command::new("tar")
3924 .arg("-C")
3925 .arg(&dir)
3926 .arg("-czf")
3927 .arg(&archive)
3928 .arg("bin")
3929 .output()
3930 .unwrap();
3931 assert!(
3932 out.status.success(),
3933 "{}",
3934 String::from_utf8_lossy(&out.stderr)
3935 );
3936 let err = refuse_unsafe_archive(&archive).unwrap_err().to_string();
3937 assert!(err.contains("symlink"), "{err}");
3938 assert!(err.contains("nothing was installed"), "{err}");
3939 }
3940}
3941
3942#[cfg(test)]
3943mod tests {
3944 use super::*;
3945 use std::os::unix::fs::PermissionsExt;
3946
3947 const SECRET: &str = "AGE-SECRET-KEY-1PLANTED\n";
3948
3949 fn temp_of(path: &Path) -> PathBuf {
3950 path.with_file_name(format!(".cloud.age.tmp-{}", std::process::id()))
3951 }
3952
3953 #[test]
3954 fn a_link_planted_at_the_temp_name_is_not_followed() {
3955 let dir = tempfile::tempdir().unwrap();
3956 let path = dir.path().join("cloud.age");
3957 let outside = dir.path().join("outside");
3958 fs::write(&outside, "keep\n").unwrap();
3959 let tmp = temp_of(&path);
3960 std::os::unix::fs::symlink(&outside, &tmp).unwrap();
3961 assert!(write_private(&path, SECRET).is_err());
3962 assert_eq!(fs::read_to_string(&outside).unwrap(), "keep\n");
3963 assert!(fs::symlink_metadata(&tmp).unwrap().file_type().is_symlink());
3964 assert!(!path.exists());
3965 }
3966
3967 #[test]
3968 fn a_readable_file_planted_at_the_temp_name_never_holds_the_identity() {
3969 let dir = tempfile::tempdir().unwrap();
3970 let path = dir.path().join("cloud.age");
3971 let tmp = temp_of(&path);
3972 fs::write(&tmp, "planted\n").unwrap();
3973 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)).unwrap();
3974 assert!(write_private(&path, SECRET).is_err());
3975 assert_eq!(fs::read_to_string(&tmp).unwrap(), "planted\n");
3976 assert_eq!(
3977 fs::metadata(&tmp).unwrap().permissions().mode() & 0o777,
3978 0o644
3979 );
3980 assert!(!path.exists());
3981
3982 fs::remove_file(&tmp).unwrap();
3983 write_private(&path, SECRET).unwrap();
3984 assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
3985 assert_eq!(
3986 fs::symlink_metadata(&path).unwrap().permissions().mode() & 0o777,
3987 0o600
3988 );
3989 assert!(!tmp.exists());
3990 }
3991
3992 #[test]
3995 fn a_launchd_app_looks_for_a_bare_server_on_its_own_path() {
3996 let scratch = tempfile::tempdir().unwrap();
3997 let bin = scratch.path().join("bin");
3998 let empty = scratch.path().join("empty");
3999 fs::create_dir_all(&bin).unwrap();
4000 fs::create_dir_all(&empty).unwrap();
4001 let server = bin.join("ljos-mcp");
4002 fs::write(&server, "#!/bin/sh\n").unwrap();
4003 fs::set_permissions(&server, fs::Permissions::from_mode(0o755)).unwrap();
4004 let launch = LaunchSpec {
4005 command: "ljos-mcp".into(),
4006 args: Vec::new(),
4007 env_path: None,
4008 };
4009 let file = |launch_path: &Path| McpFile {
4010 path: scratch.path().join("claude_desktop_config.json"),
4011 probe: McpProbe::Json("/mcpServers/ljos".into()),
4012 advice: "ljos onboard --harness claude-desktop".into(),
4013 expand: Expand::Shell,
4014 launch_path: Some(launch_path.display().to_string()),
4015 strict: false,
4016 };
4017 match launch_check(&file(&empty), &launch) {
4018 LaunchCheck::Missing(why) => {
4019 assert!(why.contains(&empty.display().to_string()), "{why}");
4020 }
4021 _ => panic!("ljos-mcp is not on the app's PATH"),
4022 }
4023 assert!(!matches!(
4024 launch_check(&file(&bin), &launch),
4025 LaunchCheck::Missing(_)
4026 ));
4027 }
4028
4029 #[test]
4030 fn mode_fix_refuses_a_symlink_planted_at_the_target_path() {
4031 let dir = tempfile::tempdir().unwrap();
4032 let path = dir.path().join("cloud.age");
4033 let outside = dir.path().join("outside");
4034 fs::write(&path, SECRET).unwrap();
4035 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4036 fs::write(&outside, "keep\n").unwrap();
4037 fs::set_permissions(&outside, fs::Permissions::from_mode(0o644)).unwrap();
4038 fs::remove_file(&path).unwrap();
4039 std::os::unix::fs::symlink(&outside, &path).unwrap();
4040
4041 assert!(set_mode_0600(&path).is_err());
4042 assert!(fs::symlink_metadata(&path)
4043 .unwrap()
4044 .file_type()
4045 .is_symlink());
4046 assert_eq!(
4047 fs::metadata(&outside).unwrap().permissions().mode() & 0o777,
4048 0o644,
4049 "a swapped symlink changed another file's mode"
4050 );
4051 }
4052
4053 #[test]
4056 fn mode_fix_refuses_a_symlink_parent() {
4057 let dir = tempfile::tempdir().unwrap();
4058 let real = dir.path().join("real");
4059 fs::create_dir(&real).unwrap();
4060 let path = real.join("cloud.age");
4061 fs::write(&path, SECRET).unwrap();
4062 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4063 let via_dir = dir.path().join("via");
4064 std::os::unix::fs::symlink(&real, &via_dir).unwrap();
4065 let via = via_dir.join("cloud.age");
4066
4067 let err = set_mode_0600(&via).unwrap_err();
4068 assert!(err.to_string().contains("symlink"), "{err}");
4069 assert_eq!(
4070 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4071 0o644,
4072 "a parent symlink changed the target's mode"
4073 );
4074
4075 let err = match install_identity(&via, SECRET) {
4076 Err(err) => err,
4077 Ok(_) => panic!("parent symlink was accepted"),
4078 };
4079 assert!(err.to_string().contains("symlink"), "{err}");
4080 assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
4081 assert_eq!(
4082 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4083 0o644
4084 );
4085
4086 let err = match install_identity(&via, "AGE-SECRET-KEY-1OTHER\n") {
4087 Err(err) => err,
4088 Ok(_) => panic!("parent symlink was written through"),
4089 };
4090 assert!(err.to_string().contains("symlink"), "{err}");
4091 assert_eq!(
4092 fs::read_to_string(&path).unwrap(),
4093 SECRET,
4094 "the identity was written through the parent symlink"
4095 );
4096 assert_eq!(
4097 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4098 0o644
4099 );
4100
4101 set_mode_0600(&path).unwrap();
4102 assert_eq!(
4103 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4104 0o600,
4105 "a direct path is still mode 0600"
4106 );
4107 }
4108
4109 #[test]
4110 fn a_renamed_server_key_counts_when_its_command_is_the_seat() {
4111 let doc = serde_json::json!({
4112 "mcpServers": {
4113 "my__seat": {"command": "/usr/local/bin/ljos-mcp", "args": []},
4114 "github": {"command": "github-mcp"},
4115 "off": {"command": "ljos-mcp", "disabled": true},
4116 "via": {"command": "ljos", "args": ["mcp"]}
4117 }
4118 });
4119 let keys = json_seat_keys(&doc);
4120 assert!(keys.contains("my__seat"), "{keys:?}");
4121 assert!(keys.contains("via"), "{keys:?}");
4122 assert!(!keys.contains("github"), "{keys:?}");
4123 assert!(!keys.contains("off"), "{keys:?}");
4124 }
4125
4126 #[test]
4127 fn a_toml_server_table_names_the_seat_key() {
4128 let doc: toml::Value =
4129 toml::from_str("[mcp_servers.ljos]\ncommand = \"ljos-mcp\"\nargs = []\n").unwrap();
4130 let keys = toml_seat_keys(&doc);
4131 assert!(keys.contains("ljos"), "{keys:?}");
4132 let other: toml::Value =
4133 toml::from_str("[mcp_servers.github]\ncommand = \"github-mcp\"\n").unwrap();
4134 assert!(!toml_seat_keys(&other).contains("github"));
4135 }
4136
4137 #[test]
4138 fn a_yaml_marker_names_the_seat_when_the_command_matches() {
4139 let text = "extensions:\n ljos:\n command: ljos-mcp\n args: []\n";
4140 assert_eq!(
4141 text_marker_key(text, " ljos:\n command:"),
4142 Some("ljos".to_string())
4143 );
4144 assert_eq!(text_marker_key(text, " other:\n command:"), None);
4145 }
4146
4147 #[test]
4148 fn a_strict_config_that_is_not_a_file_is_ambiguous() {
4149 let dir = tempfile::tempdir().unwrap();
4150 let path = dir.path().join("mcp.json");
4151 fs::create_dir(&path).unwrap();
4152 let file = McpFile {
4153 path: path.clone(),
4154 probe: McpProbe::Json("/mcpServers/ljos".into()),
4155 advice: String::new(),
4156 expand: Expand::Shell,
4157 launch_path: None,
4158 strict: true,
4159 };
4160 assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4161 let missing = McpFile {
4162 path: dir.path().join("absent.json"),
4163 probe: McpProbe::Json("/mcpServers/ljos".into()),
4164 advice: String::new(),
4165 expand: Expand::Shell,
4166 launch_path: None,
4167 strict: true,
4168 };
4169 assert!(matches!(read_seat_keys(&missing), KeyRead::Absent));
4170 fs::remove_dir(&path).unwrap();
4171 fs::write(&path, "{not json").unwrap();
4172 assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4173 let loose = McpFile {
4174 strict: false,
4175 ..file
4176 };
4177 assert!(matches!(read_seat_keys(&loose), KeyRead::Skip));
4178 }
4179
4180 #[test]
4181 fn a_payload_command_names_the_seat_server() {
4182 assert!(payload_command_is_seat("ljos-mcp"));
4183 assert!(payload_command_is_seat("/opt/ljos/bin/ljos-mcp"));
4184 assert!(payload_command_is_seat("ljos-plugin ljos-mcp"));
4185 assert!(payload_command_is_seat("ljos mcp"));
4186 assert!(!payload_command_is_seat("github-mcp-server"));
4187 assert!(!payload_command_is_seat("ljos-plugin"));
4188 assert!(!payload_command_is_seat(""));
4189 }
4190
4191 #[test]
4192 fn a_large_claude_document_yields_only_mcp_servers() {
4193 let history = "x".repeat(1000);
4194 let mut body = String::from(
4195 r#"{"mcpServers":{"ljos.seat":{"command":"ljos-mcp","args":[]}},"projects":{"/w":{"history":["#,
4196 );
4197 body.push('"');
4198 body.push_str(&history);
4199 body.push_str(r#""]}}}"#);
4200 let found = claude_mcp_servers_value(&mut body.as_bytes());
4201 let ClaudeRead::Servers(text) = found else {
4202 panic!("mcpServers was not read");
4203 };
4204 assert!(text.contains("ljos.seat"), "{text}");
4205 assert!(!text.contains(&history[..32]), "history was kept: {text}");
4206 let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
4207 let keys = json_seat_keys(&doc);
4208 assert!(keys.contains("ljos.seat"), "{keys:?}");
4209 assert!(keys.contains("ljos_seat"), "{keys:?}");
4210 }
4211}