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