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 = crate::git_cmd()
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 = crate::git_cmd()
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 = crate::git_cmd()
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) = crate::git_cmd()
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 let unwound = unwind_system_links(path);
3073 let path = unwound.as_path();
3074 refuse_symlink_parent(path)?;
3080 let regular = fs::symlink_metadata(path)
3084 .ok()
3085 .is_some_and(|meta| meta.file_type().is_file());
3086 if regular && fs::read_to_string(path).ok().as_deref() == Some(text) {
3087 if file_mode(path) == Some(0o600) {
3088 return Ok(Install::Same);
3089 }
3090 set_mode_0600(path)?;
3091 return Ok(Install::Mode);
3092 }
3093 write_private(path, text)?;
3094 Ok(Install::Wrote)
3095}
3096
3097fn file_mode(path: &Path) -> Option<u32> {
3098 use std::os::unix::fs::PermissionsExt;
3099 fs::symlink_metadata(path)
3100 .ok()
3101 .filter(|meta| meta.file_type().is_file())
3102 .map(|meta| meta.permissions().mode() & 0o777)
3103}
3104
3105fn ancestor_is_symlink(path: &Path) -> bool {
3108 let Some(parent) = path.parent() else {
3109 return false;
3110 };
3111 let mut walked = PathBuf::new();
3112 for component in parent.components() {
3113 match component {
3114 std::path::Component::RootDir => walked.push(component),
3115 std::path::Component::CurDir => {}
3116 std::path::Component::Normal(name) => {
3117 walked.push(name);
3118 if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
3119 return true;
3120 }
3121 }
3122 _ => return true,
3123 }
3124 }
3125 false
3126}
3127
3128fn system_owned_symlink(path: &Path) -> bool {
3132 use std::os::unix::fs::MetadataExt;
3133 let Ok(meta) = fs::symlink_metadata(path) else {
3134 return false;
3135 };
3136 if !meta.file_type().is_symlink() || meta.uid() != 0 {
3137 return false;
3138 }
3139 let Some(parent) = path.parent() else {
3140 return false;
3141 };
3142 let Ok(dir) = fs::symlink_metadata(parent) else {
3143 return false;
3144 };
3145 if dir.uid() != 0 {
3146 return false;
3147 }
3148 let mode = dir.mode();
3149 if mode & 0o002 != 0 {
3150 return false;
3151 }
3152 if mode & 0o020 != 0 && dir.gid() != 0 {
3153 return false;
3154 }
3155 true
3156}
3157
3158fn unwind_system_links(path: &Path) -> PathBuf {
3162 fn walk(path: &Path, depth: u8) -> PathBuf {
3163 if depth > 8 {
3164 return path.to_path_buf();
3165 }
3166 let mut out = PathBuf::new();
3167 for component in path.components() {
3168 match component {
3169 std::path::Component::RootDir => out.push(component),
3170 std::path::Component::CurDir => {}
3171 std::path::Component::Normal(name) => {
3172 out.push(name);
3173 if system_owned_symlink(&out) {
3174 let Ok(target) = fs::read_link(&out) else {
3175 continue;
3176 };
3177 let next = if target.is_absolute() {
3178 target
3179 } else {
3180 out.parent().unwrap_or(Path::new("/")).join(target)
3181 };
3182 out = walk(&next, depth + 1);
3183 }
3184 }
3185 _ => return path.to_path_buf(),
3186 }
3187 }
3188 out
3189 }
3190 walk(path, 0)
3191}
3192
3193fn refuse_symlink_parent(path: &Path) -> Result<()> {
3194 if ancestor_is_symlink(path) {
3195 bail!(
3196 "cloud up: {} is reached through a symlink, so the scope cloud identity was not written",
3197 path.display()
3198 );
3199 }
3200 Ok(())
3201}
3202
3203fn open_nofollow(path: &Path) -> std::io::Result<fs::File> {
3206 use std::os::fd::{AsRawFd, FromRawFd};
3207 use std::os::unix::ffi::OsStrExt;
3208 use std::os::unix::fs::OpenOptionsExt;
3209 let absolute;
3210 let path = if path.is_absolute() {
3211 path
3212 } else {
3213 absolute = std::env::current_dir()?.join(path);
3214 absolute.as_path()
3215 };
3216 let mut names = Vec::new();
3217 for component in path.components() {
3218 match component {
3219 std::path::Component::RootDir | std::path::Component::CurDir => {}
3220 std::path::Component::Normal(name) => names.push(name),
3221 _ => {
3222 return Err(std::io::Error::new(
3223 std::io::ErrorKind::InvalidInput,
3224 "path is not direct",
3225 ));
3226 }
3227 }
3228 }
3229 let mut current = fs::OpenOptions::new()
3230 .read(true)
3231 .custom_flags(libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
3232 .open("/")?;
3233 for (index, name) in names.iter().enumerate() {
3234 let last = index + 1 == names.len();
3235 let mut bytes = name.as_bytes().to_vec();
3236 if bytes.contains(&0) {
3237 return Err(std::io::Error::new(
3238 std::io::ErrorKind::InvalidInput,
3239 "nul in path",
3240 ));
3241 }
3242 bytes.push(0);
3243 let mut flags = libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_RDONLY;
3244 if !last {
3245 flags |= libc::O_DIRECTORY;
3246 }
3247 let fd = unsafe { libc::openat(current.as_raw_fd(), bytes.as_ptr().cast(), flags) };
3250 if fd < 0 {
3251 return Err(std::io::Error::last_os_error());
3252 }
3253 current = unsafe { fs::File::from_raw_fd(fd) };
3255 }
3256 Ok(current)
3257}
3258
3259fn set_mode_0600(path: &Path) -> Result<()> {
3260 use std::os::unix::fs::PermissionsExt;
3261 let unwound = unwind_system_links(path);
3262 let path = unwound.as_path();
3263 let linked = ancestor_is_symlink(path)
3266 || fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink());
3267 if linked {
3268 bail!("mode 0600 {}: path is a symlink", path.display());
3269 }
3270 let file = match open_nofollow(path) {
3271 Ok(file) => file,
3272 Err(err) if matches!(err.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR)) => {
3273 bail!("mode 0600 {}: path is a symlink", path.display());
3274 }
3275 Err(err) => {
3276 return Err(err).with_context(|| format!("mode 0600 {}", path.display()));
3277 }
3278 };
3279 if !file.metadata().is_ok_and(|meta| meta.file_type().is_file()) {
3280 bail!("mode 0600 {}: target is not a file", path.display());
3281 }
3282 file.set_permissions(fs::Permissions::from_mode(0o600))
3283 .with_context(|| format!("mode 0600 {}", path.display()))
3284}
3285
3286fn write_private(path: &Path, text: &str) -> Result<()> {
3292 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
3293 let unwound = unwind_system_links(path);
3294 let path = unwound.as_path();
3295 refuse_symlink_parent(path)?;
3296 if let Some(dir) = path.parent() {
3297 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
3298 }
3299 if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
3300 bail!(
3301 "cloud up: {} is a symlink, so the scope cloud identity was not written; remove it and run `ljos cloud up` again",
3302 path.display()
3303 );
3304 }
3305 let name = path
3306 .file_name()
3307 .and_then(|n| n.to_str())
3308 .unwrap_or("cloud.age");
3309 let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
3310 let mut file = fs::OpenOptions::new()
3311 .write(true)
3312 .create_new(true)
3313 .mode(0o600)
3314 .custom_flags(libc::O_NOFOLLOW)
3315 .open(&tmp)
3316 .with_context(|| format!("create {}", tmp.display()))?;
3317 let write = (|| -> Result<()> {
3318 file.set_permissions(fs::Permissions::from_mode(0o600))?;
3320 file.write_all(text.as_bytes())?;
3321 file.sync_all()?;
3322 fs::rename(&tmp, path).with_context(|| format!("install {}", path.display()))?;
3323 Ok(())
3324 })();
3325 if write.is_err() {
3326 let _ = fs::remove_file(&tmp);
3327 }
3328 write
3329}
3330
3331fn path_export_line(bin_dir: &Path) -> String {
3332 format!("export PATH=\"{}:$PATH\"", bin_dir.display())
3333}
3334
3335fn first_interactive_guard(text: &str) -> Option<usize> {
3336 let lines: Vec<&str> = text.split_inclusive('\n').collect();
3337 let mut at = 0usize;
3338 for (i, line) in lines.iter().enumerate() {
3339 let trimmed = line.trim();
3340 let one_line =
3341 (trimmed.contains("$PS1") || trimmed.contains("$-")) && trimmed.contains("return");
3342 let rest = &lines[i + 1..];
3348 if one_line
3349 || (case_tests_shell(trimmed) && block_returns(rest, "esac"))
3350 || (if_tests_shell(trimmed) && block_returns(rest, "fi"))
3351 {
3352 return Some(at);
3353 }
3354 at += line.len();
3355 }
3356 None
3357}
3358
3359fn case_tests_shell(trimmed: &str) -> bool {
3361 let Some(rest) = trimmed.strip_prefix("case") else {
3362 return false;
3363 };
3364 let rest = rest.trim_start();
3365 let word = rest.split_whitespace().next().unwrap_or("");
3366 let word = word.trim_matches(|c| c == '"' || c == '\'');
3367 word == "$-" || word == "$PS1"
3368}
3369
3370fn if_tests_shell(trimmed: &str) -> bool {
3373 trimmed.starts_with("if ") && (trimmed.contains("$PS1") || trimmed.contains("$-"))
3374}
3375
3376fn block_returns(lines: &[&str], end: &str) -> bool {
3379 for line in lines {
3380 let trimmed = line.trim();
3381 let first = trimmed.split([';', ' ', '\t']).next().unwrap_or("");
3382 if first == end {
3383 return false;
3384 }
3385 if trimmed.contains("return") {
3386 return true;
3387 }
3388 }
3389 false
3390}
3391
3392fn ensure_path_file(path: &Path, bin_dir: &Path, before_interactive_guard: bool) -> Result<String> {
3393 let have = fs::read_to_string(path).unwrap_or_default();
3394 let line = path_export_line(bin_dir);
3395 if have.lines().any(|l| l.trim() == line) {
3396 return Ok(format!(
3397 "path: {} already names {}",
3398 path.display(),
3399 bin_dir.display()
3400 ));
3401 }
3402 let mut out = have;
3403 if before_interactive_guard {
3404 if let Some(at) = first_interactive_guard(&out) {
3405 let mut next = String::with_capacity(out.len() + line.len() + 1);
3406 next.push_str(&out[..at]);
3407 next.push_str(&line);
3408 next.push('\n');
3409 next.push_str(&out[at..]);
3410 out = next;
3411 } else {
3412 if !out.is_empty() && !out.ends_with('\n') {
3413 out.push('\n');
3414 }
3415 out.push_str(&line);
3416 out.push('\n');
3417 }
3418 } else {
3419 if !out.is_empty() && !out.ends_with('\n') {
3420 out.push('\n');
3421 }
3422 out.push_str(&line);
3423 out.push('\n');
3424 }
3425 if let Some(parent) = path.parent() {
3426 fs::create_dir_all(parent)?;
3427 }
3428 fs::write(path, out).with_context(|| format!("write {}", path.display()))?;
3429 Ok(format!(
3430 "path: {} adds {}",
3431 path.display(),
3432 bin_dir.display()
3433 ))
3434}
3435
3436fn ensure_process_path(bin_dir: &Path) -> String {
3439 let dir = bin_dir.display().to_string();
3440 let current = std::env::var("PATH").unwrap_or_default();
3441 let entries: Vec<&str> = current
3442 .split(':')
3443 .filter(|entry| !entry.is_empty())
3444 .collect();
3445 if entries.first() == Some(&dir.as_str()) {
3446 return format!("path: PATH already starts with {dir}");
3447 }
3448 let mut next = dir.clone();
3449 for entry in entries.into_iter().filter(|entry| *entry != dir) {
3450 next.push(':');
3451 next.push_str(entry);
3452 }
3453 crate::with_env_mutation(|| std::env::set_var("PATH", &next));
3454 format!("path: PATH starts with {dir}")
3455}
3456
3457fn release_started(id: &str, holder: &str) -> Result<Option<String>, String> {
3458 let layout = vissue_core::Layout::resolve(None, None).map_err(|e| e.to_string())?;
3459 let found = vissue_core::store::find_by_id(&layout, id).map_err(|e| e.to_string())?;
3460 let Some((_, path, project)) = found else {
3461 return Ok(None);
3462 };
3463 vissue_core::store::with_issues_lock(&path, || {
3464 let mut doc = vissue_core::IssueDoc::parse_file(&project, &path)?;
3465 let Some(heading) = doc.headings.iter_mut().find(|h| h.id == id) else {
3466 return Ok(None);
3467 };
3468 let Some(who) = heading.claimed_by().map(str::to_string) else {
3469 return Ok(None);
3470 };
3471 if !crate::same_holder(&who, holder) {
3472 return Ok(None);
3473 }
3474 heading.release_claim();
3475 if heading.state == "STARTED" {
3476 heading.record_state_change("TODO");
3477 }
3478 doc.write()?;
3479 Ok(Some(format!(
3480 "tracker: {id} no longer claimed by {who}; STARTED handed back"
3481 )))
3482 })
3483 .map_err(|e| e.to_string())
3484}
3485
3486fn local_hold(assignee: &str, issue: &str) -> Option<bool> {
3491 let entries = fs::read_dir(crate::runtime_dir()).ok()?;
3492 let mut found = None;
3493 for ent in entries.flatten() {
3494 let name = ent.file_name().to_string_lossy().into_owned();
3495 let Some(actor) = name.strip_prefix("hold-") else {
3496 continue;
3497 };
3498 let naming = hold_names_claim(actor, assignee, issue);
3499 if naming == HoldNames::Other {
3500 continue;
3501 }
3502 let Some(hold) = crate::read_hold(actor) else {
3503 continue;
3504 };
3505 if crate::hold_alive(&hold) {
3506 return Some(true);
3507 }
3508 if naming == HoldNames::Issue {
3511 found = Some(false);
3512 }
3513 }
3514 found
3515}
3516
3517#[derive(Debug, PartialEq, Eq)]
3518enum HoldNames {
3519 Issue,
3521 AnyOfSeat,
3524 Other,
3525}
3526
3527fn hold_names_claim(actor: &str, assignee: &str, issue: &str) -> HoldNames {
3535 let path = crate::runtime_dir().join(format!("hold-{actor}"));
3536 let Ok(text) = fs::read_to_string(path) else {
3537 return HoldNames::Other;
3538 };
3539 let lines: Vec<&str> = text.lines().map(str::trim).collect();
3540 let record_assignee = lines.first().copied().unwrap_or("");
3541 let node = lines.get(5).copied().unwrap_or("");
3542 let name_ok =
3543 crate::same_holder(actor, assignee) || crate::same_holder(record_assignee, assignee);
3544 if !name_ok {
3545 return HoldNames::Other;
3546 }
3547 if node.is_empty() {
3548 return if crate::same_holder(actor, assignee) {
3549 HoldNames::AnyOfSeat
3550 } else {
3551 HoldNames::Other
3552 };
3553 }
3554 if node == issue {
3555 HoldNames::Issue
3556 } else {
3557 HoldNames::Other
3558 }
3559}
3560
3561fn bin_dir(home: &Path) -> PathBuf {
3562 std::env::var_os("LJOS_BIN_DIR")
3563 .filter(|p| !p.is_empty())
3564 .map(PathBuf::from)
3565 .unwrap_or_else(|| home.join(".local/bin"))
3566}
3567
3568fn tracker_dir(home: &Path) -> PathBuf {
3569 std::env::var_os("LJOS_TRACKER_DIR")
3570 .filter(|p| !p.is_empty())
3571 .map(PathBuf::from)
3572 .unwrap_or_else(|| home.join(".local/share/ljos/tracker"))
3573}
3574
3575fn seat_matches_file() -> bool {
3576 let Some(file) = env_file_value("LJOS_SEAT") else {
3577 return false;
3578 };
3579 std::env::var("LJOS_SEAT").ok().as_deref() == Some(file.as_str())
3580}
3581
3582fn env_file_value(key: &str) -> Option<String> {
3583 let home = crate::home().ok()?;
3584 let text = fs::read_to_string(home.join(".config/ljos/env")).ok()?;
3585 for line in text.lines() {
3586 let line = line.trim();
3587 let Some((k, v)) = line.split_once('=') else {
3588 continue;
3589 };
3590 if k.trim() == key {
3591 return Some(v.trim().to_string());
3592 }
3593 }
3594 None
3595}
3596
3597fn cursor_mcp_advice() -> String {
3601 if on_cloud_vm() {
3602 "ljos cloud up".into()
3603 } else {
3604 "ljos onboard --harness cursor".into()
3605 }
3606}
3607
3608fn on_cloud_vm() -> bool {
3609 let rt = runtime();
3610 if agent_id().is_some() || rt.ci || rt.codespace {
3611 return true;
3612 }
3613 if rt.cursor_harness && rt.cloud_agent {
3616 return true;
3617 }
3618 cloud_env_file_seat()
3619}
3620
3621fn runtime() -> Runtime {
3625 let github_actions = env_is_true("GITHUB_ACTIONS");
3626 let cloud_id =
3627 nonempty_var("CURSOR_AGENT_ID").is_some() || nonempty_var("CLOUD_AGENT_ID").is_some();
3628 let api_socket = Path::new("/run/cursor/api.sock").exists();
3629 let cloud_agent = cloud_id
3632 || nonempty_var("CURSOR_AGENT_SOCKET").is_some()
3633 || nonempty_var("CURSOR_CONVERSATION_ID").is_some();
3634 let cursor_harness = cloud_agent
3635 || api_socket
3636 || std::env::var_os("CURSOR_AGENT").is_some()
3637 || std::env::var_os("CURSOR_AGENT_ID").is_some();
3638 let codespace = env_is_true("CODESPACES") || nonempty_var("CODESPACE_NAME").is_some();
3639 let ci = github_actions
3644 || env_is_true("GITLAB_CI")
3645 || env_is_true("CI")
3646 || env_is_true("BUILDKITE")
3647 || env_is_true("TF_BUILD")
3648 || env_is_true("CIRCLECI")
3649 || nonempty_var("JENKINS_URL").is_some();
3650 Runtime {
3651 github_actions,
3652 ci,
3653 codespace,
3654 cloud_id,
3655 api_socket,
3656 cursor_harness,
3657 cloud_agent,
3658 }
3659}
3660
3661struct Runtime {
3662 github_actions: bool,
3663 ci: bool,
3664 codespace: bool,
3665 cloud_id: bool,
3666 api_socket: bool,
3667 cursor_harness: bool,
3668 cloud_agent: bool,
3669}
3670
3671fn env_is_true(key: &str) -> bool {
3673 std::env::var(key)
3674 .ok()
3675 .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
3676}
3677
3678fn cloud_env_file_seat() -> bool {
3681 let Some(seat) = env_file_value("LJOS_SEAT") else {
3682 return false;
3683 };
3684 ["cursor-", "ci-", "codespace-"]
3685 .iter()
3686 .any(|prefix| seat.starts_with(prefix))
3687}
3688
3689fn nonempty_var(key: &str) -> Option<String> {
3690 std::env::var(key)
3691 .ok()
3692 .map(|v| v.trim().to_string())
3693 .filter(|v| !v.is_empty())
3694}
3695
3696fn agent_id() -> Option<String> {
3697 if github_actions() {
3700 if let Some(id) = id_slug("GITHUB_RUN_ID") {
3701 return Some(id);
3702 }
3703 }
3704 for key in [
3705 "CURSOR_AGENT_ID",
3706 "CURSOR_AGENT",
3707 "CLOUD_AGENT_ID",
3708 "AGENT_ID",
3709 "CURSOR_CONVERSATION_ID",
3710 "CODESPACE_NAME",
3711 "GITHUB_RUN_ID",
3712 ] {
3713 if let Some(id) = id_slug(key) {
3714 return Some(id);
3715 }
3716 }
3717 None
3718}
3719
3720fn github_actions() -> bool {
3721 runtime().github_actions
3722}
3723
3724fn id_slug(key: &str) -> Option<String> {
3725 run_slug(&nonempty_var(key)?)
3726}
3727
3728fn run_slug(id: &str) -> Option<String> {
3729 let id = id.trim();
3730 if matches!(id, "0" | "1" | "true" | "false") {
3731 return None;
3732 }
3733 let slug = crate::seat_slug(id);
3734 if slug.is_empty() || slug == "runner" {
3735 return None;
3736 }
3737 Some(slug.chars().take(16).collect())
3738}
3739
3740fn harness_kind() -> &'static str {
3741 let rt = runtime();
3742 if rt.cursor_harness {
3743 "cursor"
3744 } else if rt.codespace {
3745 "codespace"
3746 } else if rt.ci {
3747 "ci"
3748 } else {
3749 "vm"
3750 }
3751}
3752
3753fn hostname() -> String {
3754 fs::read_to_string("/etc/hostname")
3755 .ok()
3756 .map(|s| s.trim().to_string())
3757 .filter(|s| !s.is_empty())
3758 .unwrap_or_else(|| "vm".into())
3759}
3760
3761#[derive(Debug)]
3762struct ArchiveMember {
3763 kind: u8,
3765 name: Vec<u8>,
3766}
3767
3768fn refuse_unsafe_archive(file: &Path) -> Result<()> {
3771 let listing = tar_verbose_listing(file)?;
3772 let members = match parse_tar_listing(&listing) {
3773 Ok(members) => members,
3774 Err(err) => bail!("tar {}: {err}; nothing was installed", file.display()),
3775 };
3776 for member in members {
3777 let shown = String::from_utf8_lossy(&member.name);
3778 if member.name.is_empty() || member.name.contains(&0) {
3779 bail!(
3780 "encoder archive {}: a member path is empty; nothing was installed",
3781 file.display()
3782 );
3783 }
3784 if !archive_member_path_ok(&member.name) {
3785 bail!(
3786 "encoder archive {}: member {shown} has an absolute path or '..'; nothing was installed",
3787 file.display()
3788 );
3789 }
3790 match member.kind {
3791 b'-' | b'd' => {}
3792 b'l' => bail!(
3793 "encoder archive {}: member {shown} is a symlink; nothing was installed",
3794 file.display()
3795 ),
3796 b'h' => bail!(
3797 "encoder archive {}: member {shown} is a hard link; nothing was installed",
3798 file.display()
3799 ),
3800 _ => bail!(
3801 "encoder archive {}: member {shown} is not a regular file; nothing was installed",
3802 file.display()
3803 ),
3804 }
3805 }
3806 Ok(())
3807}
3808
3809fn archive_member_path_ok(name: &[u8]) -> bool {
3810 use std::os::unix::ffi::OsStrExt;
3811 use std::path::Component;
3812 if name.is_empty() || name.contains(&0) || name[0] == b'/' {
3813 return false;
3814 }
3815 let path = Path::new(std::ffi::OsStr::from_bytes(name));
3816 if path.is_absolute() {
3817 return false;
3818 }
3819 path.components()
3820 .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
3821}
3822
3823fn tar_verbose_listing(file: &Path) -> Result<Vec<u8>> {
3827 let run = |args: &[&str]| {
3828 Command::new("tar")
3829 .args(args)
3830 .arg(file)
3831 .stdin(std::process::Stdio::null())
3832 .env("LC_ALL", "C")
3833 .output()
3834 .context("tar")
3835 };
3836 let gnu = run(&["--full-time", "--quoting-style=shell-always", "-tvzf"])?;
3837 if gnu.status.success() {
3838 return Ok(gnu.stdout);
3839 }
3840 let err = String::from_utf8_lossy(&gnu.stderr);
3841 let unsupported = err.contains("not supported")
3842 || err.to_ascii_lowercase().contains("unrecognized")
3843 || err.to_ascii_lowercase().contains("unknown option")
3844 || err.to_ascii_lowercase().contains("illegal option");
3845 if !unsupported {
3846 bail!(
3847 "tar {}: {}; nothing was installed",
3848 file.display(),
3849 err.trim()
3850 );
3851 }
3852 let plain = run(&["-tvzf"])?;
3853 if !plain.status.success() {
3854 bail!(
3855 "tar {}: {}; nothing was installed",
3856 file.display(),
3857 String::from_utf8_lossy(&plain.stderr).trim()
3858 );
3859 }
3860 Ok(plain.stdout)
3861}
3862
3863fn parse_tar_listing(bytes: &[u8]) -> Result<Vec<ArchiveMember>> {
3866 if let Ok(members) = parse_quoted_tar_listing(bytes) {
3867 if !members.is_empty() || bytes.iter().all(|byte| *byte == b'\n') {
3868 return Ok(members);
3869 }
3870 }
3871 parse_plain_tar_listing(bytes)
3872}
3873
3874fn parse_quoted_tar_listing(bytes: &[u8]) -> Result<Vec<ArchiveMember>> {
3875 let mut members = Vec::new();
3876 let mut i = 0;
3877 while i < bytes.len() {
3878 if bytes[i] == b'\n' {
3879 i += 1;
3880 continue;
3881 }
3882 let kind = bytes[i];
3883 let quote = bytes[i..]
3884 .iter()
3885 .position(|b| *b == b'\'')
3886 .context("unquoted tar member")?;
3887 let start = i + quote;
3888 let (name, used) = parse_shell_single(&bytes[start..])?;
3889 let mut j = start + used;
3890 let rest = &bytes[j..];
3891 if rest.starts_with(b" -> ") || rest.starts_with(b" link to ") {
3892 let target = rest
3893 .iter()
3894 .position(|b| *b == b'\'')
3895 .context("unquoted link target")?;
3896 let (_, used_target) = parse_shell_single(&rest[target..])?;
3897 j += target + used_target;
3898 }
3899 if j < bytes.len() && bytes[j] == b'\n' {
3900 j += 1;
3901 } else if j < bytes.len() {
3902 bail!("tar member list has trailing bytes");
3903 }
3904 members.push(ArchiveMember { kind, name });
3905 i = j;
3906 }
3907 Ok(members)
3908}
3909
3910fn parse_plain_tar_listing(bytes: &[u8]) -> Result<Vec<ArchiveMember>> {
3913 let text = String::from_utf8_lossy(bytes);
3914 let mut members = Vec::new();
3915 for line in text.split('\n') {
3916 let line = line.trim_end_matches('\r');
3917 if line.is_empty() {
3918 continue;
3919 }
3920 let kind = line.as_bytes()[0];
3921 let name = plain_member_name(line).context("tar member has no name")?;
3922 members.push(ArchiveMember {
3923 kind,
3924 name: name.into_bytes(),
3925 });
3926 }
3927 if members.is_empty() {
3928 bail!("tar member list is empty");
3929 }
3930 Ok(members)
3931}
3932
3933fn plain_member_name(line: &str) -> Option<String> {
3934 let bytes = line.as_bytes();
3935 let mut i = 0;
3936 while i + 4 < bytes.len() {
3937 let month = bytes[i].is_ascii_uppercase()
3938 && bytes[i + 1].is_ascii_lowercase()
3939 && bytes[i + 2].is_ascii_lowercase()
3940 && bytes[i + 3] == b' ';
3941 if month {
3942 let mut parts = line[i..].split_whitespace();
3943 let _month = parts.next()?;
3944 let _day = parts.next()?;
3945 let _when = parts.next()?;
3946 let name = parts.collect::<Vec<_>>().join(" ");
3947 let name = name.split(" -> ").next().unwrap_or(&name);
3948 let name = name.split(" link to ").next().unwrap_or(name);
3949 let name = name.trim();
3950 if name.is_empty() {
3951 return None;
3952 }
3953 return Some(name.to_string());
3954 }
3955 i += 1;
3956 }
3957 None
3958}
3959
3960fn parse_shell_single(bytes: &[u8]) -> Result<(Vec<u8>, usize)> {
3962 if bytes.first() != Some(&b'\'') {
3963 bail!("tar member is not shell-quoted");
3964 }
3965 let mut out = Vec::new();
3966 let mut i = 1;
3967 while i < bytes.len() {
3968 if bytes[i] == b'\'' {
3969 i += 1;
3970 if bytes.get(i) == Some(&b'\\') && bytes.get(i + 1) == Some(&b'\'') {
3971 out.push(b'\'');
3972 i += 2;
3973 if bytes.get(i) != Some(&b'\'') {
3974 bail!("broken shell quote in tar member");
3975 }
3976 i += 1;
3977 continue;
3978 }
3979 return Ok((out, i));
3980 }
3981 out.push(bytes[i]);
3982 i += 1;
3983 }
3984 bail!("unterminated shell quote in tar member")
3985}
3986
3987fn refuse_unsafe_tree(root: &Path) -> Result<()> {
3990 use std::os::unix::fs::MetadataExt;
3991 let mut pending = vec![root.to_path_buf()];
3992 while let Some(dir) = pending.pop() {
3993 for ent in fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
3994 let path = ent?.path();
3995 let meta =
3996 fs::symlink_metadata(&path).with_context(|| format!("stat {}", path.display()))?;
3997 let rel = path.strip_prefix(root).unwrap_or(path.as_path());
3998 let ft = meta.file_type();
3999 if ft.is_symlink() {
4000 bail!(
4001 "encoder archive member {} is a symlink; nothing was installed",
4002 rel.display()
4003 );
4004 }
4005 if ft.is_dir() {
4006 pending.push(path);
4007 continue;
4008 }
4009 if !ft.is_file() {
4010 bail!(
4011 "encoder archive member {} is not a regular file; nothing was installed",
4012 rel.display()
4013 );
4014 }
4015 if meta.nlink() != 1 {
4016 bail!(
4017 "encoder archive member {} is a hard link; nothing was installed",
4018 rel.display()
4019 );
4020 }
4021 }
4022 }
4023 Ok(())
4024}
4025
4026fn regular_file(path: &Path) -> bool {
4027 fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file())
4028}
4029
4030#[cfg(all(test, unix))]
4031mod archive_member_tests {
4032 use super::{archive_member_path_ok, parse_tar_listing, refuse_unsafe_archive};
4033
4034 #[test]
4035 fn shell_quotes_and_escaping_paths_parse() {
4036 let mut raw = b"-rw-r--r-- 0/0 3 1970-01-01 00:00:00 'bin/odd '".to_vec();
4037 raw.extend_from_slice(b"\\''name'\\''\nwith space'\n");
4038 raw.extend_from_slice(
4039 b"lrwxrwxrwx ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/packset-embed' -> '/etc/passwd'\n",
4040 );
4041 raw.extend_from_slice(
4042 b"hrw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/real' link to 'bin/packset-embed'\n",
4043 );
4044 raw.extend_from_slice(b"prw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/fifo'\n");
4045 raw.extend_from_slice(b"-rw-r--r-- 0/0 17 1970-01-01 00:00:00 '/bin/packset-embed'\n");
4046 raw.extend_from_slice(b"-rw-r--r-- 0/0 7 1970-01-01 00:00:00 '../escaped'\n");
4047 let members = parse_tar_listing(&raw).unwrap();
4048 assert_eq!(members.len(), 6, "{members:?}");
4049 assert_eq!(members[0].kind, b'-');
4050 assert_eq!(members[0].name, b"bin/odd 'name'\nwith space");
4051 assert_eq!(members[1].kind, b'l');
4052 assert_eq!(members[2].kind, b'h');
4053 assert_eq!(members[3].kind, b'p');
4054 assert_eq!(members[4].name, b"/bin/packset-embed");
4055 assert_eq!(members[5].name, b"../escaped");
4056 assert!(archive_member_path_ok(&members[0].name));
4057 assert!(!archive_member_path_ok(&members[4].name));
4058 assert!(!archive_member_path_ok(&members[5].name));
4059 assert!(!archive_member_path_ok(b"bin/../../escaped2"));
4060 assert!(archive_member_path_ok(
4061 b"lib/onnxruntime/libonnxruntime.so.1"
4062 ));
4063 assert!(archive_member_path_ok(b"lib/"));
4064 }
4065
4066 #[test]
4067 fn tar_listing_refuses_a_symlink_member() {
4068 let root = tempfile::tempdir().unwrap();
4069 let dir = root.path().join("tree");
4070 std::fs::create_dir_all(dir.join("bin")).unwrap();
4071 std::os::unix::fs::symlink("/etc/passwd", dir.join("bin/packset-embed")).unwrap();
4072 let archive = root.path().join("bad.tar.gz");
4073 let out = std::process::Command::new("tar")
4074 .arg("-C")
4075 .arg(&dir)
4076 .arg("-czf")
4077 .arg(&archive)
4078 .arg("bin")
4079 .output()
4080 .unwrap();
4081 assert!(
4082 out.status.success(),
4083 "{}",
4084 String::from_utf8_lossy(&out.stderr)
4085 );
4086 let err = refuse_unsafe_archive(&archive).unwrap_err().to_string();
4087 assert!(err.contains("symlink"), "{err}");
4088 assert!(err.contains("nothing was installed"), "{err}");
4089 }
4090
4091 #[test]
4094 fn a_short_tar_listing_names_a_symlink() {
4095 let raw = b"\
4096lrwxr-xr-x 0 0 0 0 Jan 1 1970 bin/packset-embed -> /etc/passwd
4097-rw-r--r-- 0 0 0 17 Jan 1 1970 bin/real
4098";
4099 let members = parse_tar_listing(raw).unwrap();
4100 assert_eq!(members.len(), 2, "{members:?}");
4101 assert_eq!(members[0].kind, b'l');
4102 assert_eq!(members[0].name, b"bin/packset-embed");
4103 assert_eq!(members[1].kind, b'-');
4104 assert_eq!(members[1].name, b"bin/real");
4105 }
4106}
4107
4108#[cfg(test)]
4109mod tests {
4110 use super::*;
4111 use std::os::unix::fs::PermissionsExt;
4112
4113 const SECRET: &str = "AGE-SECRET-KEY-1PLANTED\n";
4114
4115 fn temp_of(path: &Path) -> PathBuf {
4116 path.with_file_name(format!(".cloud.age.tmp-{}", std::process::id()))
4117 }
4118
4119 #[test]
4120 fn a_link_planted_at_the_temp_name_is_not_followed() {
4121 let dir = tempfile::tempdir().unwrap();
4122 let path = dir.path().join("cloud.age");
4123 let outside = dir.path().join("outside");
4124 fs::write(&outside, "keep\n").unwrap();
4125 let tmp = temp_of(&path);
4126 std::os::unix::fs::symlink(&outside, &tmp).unwrap();
4127 assert!(write_private(&path, SECRET).is_err());
4128 assert_eq!(fs::read_to_string(&outside).unwrap(), "keep\n");
4129 assert!(fs::symlink_metadata(&tmp).unwrap().file_type().is_symlink());
4130 assert!(!path.exists());
4131 }
4132
4133 #[test]
4134 fn a_readable_file_planted_at_the_temp_name_never_holds_the_identity() {
4135 let dir = tempfile::tempdir().unwrap();
4136 let path = dir.path().join("cloud.age");
4137 let tmp = temp_of(&path);
4138 fs::write(&tmp, "planted\n").unwrap();
4139 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)).unwrap();
4140 assert!(write_private(&path, SECRET).is_err());
4141 assert_eq!(fs::read_to_string(&tmp).unwrap(), "planted\n");
4142 assert_eq!(
4143 fs::metadata(&tmp).unwrap().permissions().mode() & 0o777,
4144 0o644
4145 );
4146 assert!(!path.exists());
4147
4148 fs::remove_file(&tmp).unwrap();
4149 write_private(&path, SECRET).unwrap();
4150 assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
4151 assert_eq!(
4152 fs::symlink_metadata(&path).unwrap().permissions().mode() & 0o777,
4153 0o600
4154 );
4155 assert!(!tmp.exists());
4156 }
4157
4158 #[test]
4161 fn a_launchd_app_looks_for_a_bare_server_on_its_own_path() {
4162 let scratch = tempfile::tempdir().unwrap();
4163 let bin = scratch.path().join("bin");
4164 let empty = scratch.path().join("empty");
4165 fs::create_dir_all(&bin).unwrap();
4166 fs::create_dir_all(&empty).unwrap();
4167 let server = bin.join("ljos-mcp");
4168 fs::write(&server, "#!/bin/sh\n").unwrap();
4169 fs::set_permissions(&server, fs::Permissions::from_mode(0o755)).unwrap();
4170 let launch = LaunchSpec {
4171 command: "ljos-mcp".into(),
4172 args: Vec::new(),
4173 env_path: None,
4174 };
4175 let file = |launch_path: &Path| McpFile {
4176 path: scratch.path().join("claude_desktop_config.json"),
4177 probe: McpProbe::Json("/mcpServers/ljos".into()),
4178 advice: "ljos onboard --harness claude-desktop".into(),
4179 expand: Expand::Shell,
4180 launch_path: Some(launch_path.display().to_string()),
4181 strict: false,
4182 };
4183 match launch_check(&file(&empty), &launch) {
4184 LaunchCheck::Missing(why) => {
4185 assert!(why.contains(&empty.display().to_string()), "{why}");
4186 }
4187 _ => panic!("ljos-mcp is not on the app's PATH"),
4188 }
4189 assert!(!matches!(
4190 launch_check(&file(&bin), &launch),
4191 LaunchCheck::Missing(_)
4192 ));
4193 }
4194
4195 #[test]
4196 fn mode_fix_refuses_a_symlink_planted_at_the_target_path() {
4197 let dir = tempfile::tempdir().unwrap();
4198 let path = dir.path().join("cloud.age");
4199 let outside = dir.path().join("outside");
4200 fs::write(&path, SECRET).unwrap();
4201 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4202 fs::write(&outside, "keep\n").unwrap();
4203 fs::set_permissions(&outside, fs::Permissions::from_mode(0o644)).unwrap();
4204 fs::remove_file(&path).unwrap();
4205 std::os::unix::fs::symlink(&outside, &path).unwrap();
4206
4207 assert!(set_mode_0600(&path).is_err());
4208 assert!(fs::symlink_metadata(&path)
4209 .unwrap()
4210 .file_type()
4211 .is_symlink());
4212 assert_eq!(
4213 fs::metadata(&outside).unwrap().permissions().mode() & 0o777,
4214 0o644,
4215 "a swapped symlink changed another file's mode"
4216 );
4217 }
4218
4219 #[test]
4222 fn mode_fix_refuses_a_symlink_parent() {
4223 let dir = tempfile::tempdir().unwrap();
4224 let real = dir.path().join("real");
4225 fs::create_dir(&real).unwrap();
4226 let path = real.join("cloud.age");
4227 fs::write(&path, SECRET).unwrap();
4228 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4229 let via_dir = dir.path().join("via");
4230 std::os::unix::fs::symlink(&real, &via_dir).unwrap();
4231 let via = via_dir.join("cloud.age");
4232
4233 let err = set_mode_0600(&via).unwrap_err();
4234 assert!(err.to_string().contains("symlink"), "{err}");
4235 assert_eq!(
4236 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4237 0o644,
4238 "a parent symlink changed the target's mode"
4239 );
4240
4241 let err = match install_identity(&via, SECRET) {
4242 Err(err) => err,
4243 Ok(_) => panic!("parent symlink was accepted"),
4244 };
4245 assert!(err.to_string().contains("symlink"), "{err}");
4246 assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
4247 assert_eq!(
4248 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4249 0o644
4250 );
4251
4252 let err = match install_identity(&via, "AGE-SECRET-KEY-1OTHER\n") {
4253 Err(err) => err,
4254 Ok(_) => panic!("parent symlink was written through"),
4255 };
4256 assert!(err.to_string().contains("symlink"), "{err}");
4257 assert_eq!(
4258 fs::read_to_string(&path).unwrap(),
4259 SECRET,
4260 "the identity was written through the parent symlink"
4261 );
4262 assert_eq!(
4263 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4264 0o644
4265 );
4266
4267 set_mode_0600(&path).unwrap();
4268 assert_eq!(
4269 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4270 0o600,
4271 "a direct path is still mode 0600"
4272 );
4273 }
4274
4275 #[test]
4276 fn a_renamed_server_key_counts_when_its_command_is_the_seat() {
4277 let doc = serde_json::json!({
4278 "mcpServers": {
4279 "my__seat": {"command": "/usr/local/bin/ljos-mcp", "args": []},
4280 "github": {"command": "github-mcp"},
4281 "off": {"command": "ljos-mcp", "disabled": true},
4282 "via": {"command": "ljos", "args": ["mcp"]}
4283 }
4284 });
4285 let keys = json_seat_keys(&doc);
4286 assert!(keys.contains("my__seat"), "{keys:?}");
4287 assert!(keys.contains("via"), "{keys:?}");
4288 assert!(!keys.contains("github"), "{keys:?}");
4289 assert!(!keys.contains("off"), "{keys:?}");
4290 }
4291
4292 #[test]
4293 fn a_toml_server_table_names_the_seat_key() {
4294 let doc: toml::Value =
4295 toml::from_str("[mcp_servers.ljos]\ncommand = \"ljos-mcp\"\nargs = []\n").unwrap();
4296 let keys = toml_seat_keys(&doc);
4297 assert!(keys.contains("ljos"), "{keys:?}");
4298 let other: toml::Value =
4299 toml::from_str("[mcp_servers.github]\ncommand = \"github-mcp\"\n").unwrap();
4300 assert!(!toml_seat_keys(&other).contains("github"));
4301 }
4302
4303 #[test]
4304 fn a_yaml_marker_names_the_seat_when_the_command_matches() {
4305 let text = "extensions:\n ljos:\n command: ljos-mcp\n args: []\n";
4306 assert_eq!(
4307 text_marker_key(text, " ljos:\n command:"),
4308 Some("ljos".to_string())
4309 );
4310 assert_eq!(text_marker_key(text, " other:\n command:"), None);
4311 }
4312
4313 #[test]
4314 fn a_strict_config_that_is_not_a_file_is_ambiguous() {
4315 let dir = tempfile::tempdir().unwrap();
4316 let path = dir.path().join("mcp.json");
4317 fs::create_dir(&path).unwrap();
4318 let file = McpFile {
4319 path: path.clone(),
4320 probe: McpProbe::Json("/mcpServers/ljos".into()),
4321 advice: String::new(),
4322 expand: Expand::Shell,
4323 launch_path: None,
4324 strict: true,
4325 };
4326 assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4327 let missing = McpFile {
4328 path: dir.path().join("absent.json"),
4329 probe: McpProbe::Json("/mcpServers/ljos".into()),
4330 advice: String::new(),
4331 expand: Expand::Shell,
4332 launch_path: None,
4333 strict: true,
4334 };
4335 assert!(matches!(read_seat_keys(&missing), KeyRead::Absent));
4336 fs::remove_dir(&path).unwrap();
4337 fs::write(&path, "{not json").unwrap();
4338 assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4339 let loose = McpFile {
4340 strict: false,
4341 ..file
4342 };
4343 assert!(matches!(read_seat_keys(&loose), KeyRead::Skip));
4344 }
4345
4346 #[test]
4347 fn a_payload_command_names_the_seat_server() {
4348 assert!(payload_command_is_seat("ljos-mcp"));
4349 assert!(payload_command_is_seat("/opt/ljos/bin/ljos-mcp"));
4350 assert!(payload_command_is_seat("ljos-plugin ljos-mcp"));
4351 assert!(payload_command_is_seat("ljos mcp"));
4352 assert!(!payload_command_is_seat("github-mcp-server"));
4353 assert!(!payload_command_is_seat("ljos-plugin"));
4354 assert!(!payload_command_is_seat(""));
4355 }
4356
4357 #[test]
4358 fn a_large_claude_document_yields_only_mcp_servers() {
4359 let history = "x".repeat(1000);
4360 let mut body = String::from(
4361 r#"{"mcpServers":{"ljos.seat":{"command":"ljos-mcp","args":[]}},"projects":{"/w":{"history":["#,
4362 );
4363 body.push('"');
4364 body.push_str(&history);
4365 body.push_str(r#""]}}}"#);
4366 let found = claude_mcp_servers_value(&mut body.as_bytes());
4367 let ClaudeRead::Servers(text) = found else {
4368 panic!("mcpServers was not read");
4369 };
4370 assert!(text.contains("ljos.seat"), "{text}");
4371 assert!(!text.contains(&history[..32]), "history was kept: {text}");
4372 let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
4373 let keys = json_seat_keys(&doc);
4374 assert!(keys.contains("ljos.seat"), "{keys:?}");
4375 assert!(keys.contains("ljos_seat"), "{keys:?}");
4376 }
4377
4378 #[test]
4379 fn encoder_ships_for_macos_and_linux_on_x86_64_and_aarch64() {
4380 for (arch, os, want) in [
4381 ("x86_64", "linux", "x86_64-unknown-linux-gnu"),
4382 ("aarch64", "linux", "aarch64-unknown-linux-gnu"),
4383 ("x86_64", "macos", "x86_64-apple-darwin"),
4384 ("aarch64", "macos", "aarch64-apple-darwin"),
4385 ] {
4386 assert_eq!(encoder_target_for(arch, os), Some(want));
4387 }
4388 assert_eq!(encoder_target_for("x86_64", "windows"), None);
4389 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
4390 let yml = fs::read_to_string(root.join(".github/workflows/release.yml")).unwrap();
4391 let triples: Vec<&str> = yml
4392 .lines()
4393 .filter_map(|l| l.trim().strip_prefix("triple: "))
4394 .collect();
4395 assert_eq!(
4396 triples,
4397 [
4398 "x86_64-unknown-linux-gnu",
4399 "aarch64-unknown-linux-gnu",
4400 "aarch64-apple-darwin",
4401 "x86_64-apple-darwin",
4402 ]
4403 );
4404 assert!(
4405 yml.contains("protoc-21.12-osx-universal_binary.zip"),
4406 "Darwin encoder build must pin protoc 3.21.12"
4407 );
4408 assert_eq!(
4409 yml.matches("ONNX_CUSTOM_PROTOC_EXECUTABLE=$PROTOC").count(),
4410 2,
4411 "both macOS and Linux encoder builds pass ONNX_CUSTOM_PROTOC_EXECUTABLE"
4412 );
4413 assert_eq!(
4414 yml.matches("ONNX_CUSTOM_PROTOC_EXECUTABLE:[A-Z]+=$PROTOC")
4415 .count(),
4416 2,
4417 "both macOS and Linux encoder builds verify ONNX_CUSTOM_PROTOC_EXECUTABLE in CMakeCache.txt"
4418 );
4419 }
4420}