Skip to main content

ljos_cli/
cloud.rs

1//! One command for a fresh machine that should share the tracker.
2//!
3//! `ljos cloud up` installs the release binaries into `~/.local/bin`,
4//! checks the published SHA-256 sums, puts that directory first on PATH,
5//! writes the harness MCP command, and clones `LJOS_TRACKER_REMOTE`.
6//! The command is always the absolute path, so a client with another PATH
7//! still starts this install. It writes a
8//! webhook wake for each `LJOS_WAKE_<SEAT>_URL`. Entries this command
9//! writes are marked `source = "env"`; any other wake stays. When
10//! `LJOS_CLOUD_AGE_SECRET` names an environment variable, it writes that
11//! variable's age identity for scope `cloud` to a mode 0600 file. On a
12//! cloud VM it also installs the release's `packset-embed` and the ONNX
13//! Runtime it links. A later boot with the binaries already installed
14//! refreshes the clone, rewrites env-owned wakes, and refreshes the scope
15//! cloud identity from the named secret.
16
17use 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";
39/// The one cloud push rule, read from the push's argv by
40/// [`crate::cloud_guard::cursor_push`].
41const CURSOR_PUSH: &str = "cloud-cursor-push";
42/// The release workflow's encoder archives, the runtime before the binary
43/// that links it. Each unpacks into the prefix above the binary directory.
44const ENCODER_ARCHIVES: &[&str] = &["onnxruntime", "packset-embed"];
45/// Where the encoder's rpath, `$ORIGIN/../lib/onnxruntime`, finds the runtime.
46const 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
52/// Names the environment variable that holds the age identity for scope
53/// `cloud`. The value is an identity file as `age-keygen` writes it, not a
54/// path. The identity stays out of the tracker.
55pub const CLOUD_AGE_SECRET: &str = "LJOS_CLOUD_AGE_SECRET";
56
57/// An identity file longer than this is refused. `age-keygen` writes a
58/// few hundred bytes.
59const CLOUD_AGE_MAX: usize = 8192;
60
61/// Install binaries, point the harness at them, and clone the shared tracker.
62///
63/// Idempotent. `force` downloads the release again.
64///
65/// # Errors
66///
67/// A checksum does not match, a required binary is missing, or the tracker
68/// remote cannot be cloned. A push that fails after a successful clone is
69/// reported and left queued; the clone itself stands.
70pub fn up(force: bool) -> Result<String> {
71    up_at(force, false)
72}
73
74/// Install binaries, point the harness at them, and clone the shared tracker.
75///
76/// `repoint` replaces the repository an earlier `cloud up` recorded. The
77/// shell guard refuses that flag, so a seat cannot move its own allowlist.
78///
79/// # Errors
80///
81/// As [`up`]. A second repository is left unrecorded when `repoint` is false.
82pub 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    // The harness spawns servers with a PATH of its own, which no edited
90    // profile reaches, so the entry names the install by its absolute path.
91    // The note still says what the PATH cloud up started with would run.
92    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        // A seat left in the env file by an earlier boot must not hide this
98        // boot's agent id. An exported seat that differs from the file stays.
99        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        // Tag this VM's memory and lessons scope:cloud. Not default_scope:
144        // an unscoped box atom must not follow a default into the cloud log.
145        (
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    // After the rule file, so a second `cloud up` still says the refusals
168    // are already installed. Git runs this hook for every push; the rule
169    // file cannot see a push a script starts.
170    lines.push(crate::cloud_hook::ensure(repoint)?);
171    // Retire env-owned wakes before any later step can fail, so a removed
172    // `LJOS_WAKE_<SEAT>_URL` cannot leave a stale URL that still sees a new key.
173    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/// The seat this boot should write under.
183///
184/// `LJOS_SEAT` when it is set, with the run id appended when this boot has
185/// one, so a reused name (`ridley-ci-a`) is a different seat on every run.
186/// Otherwise the harness name and the agent id, and the machine name when
187/// there is no agent id.
188#[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
200/// The seat a run with id `run` takes when it boots with `LJOS_SEAT` set
201/// to `base`, as [`default_seat`] names it there.
202///
203/// # Errors
204///
205/// `base` already ends with the run id. [`default_seat`] keeps such a
206/// seat as it is, so a boot that runs `cloud up` again does not append
207/// the id twice, and the bare name on that run boots as the same seat.
208pub 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
232/// The seat's signing key, created on first use. This is not the machine
233/// key at `~/.config/deedar/host.key`.
234///
235/// # Errors
236///
237/// No home directory, or the key file cannot be written.
238pub 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/// What `ljos doctor` says about the harness MCP command.
246#[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/// What `ljos doctor` says about the one cloud push rule. A marked seat
273/// is `ok` when the pre-push hook is installed. An unmarked machine is
274/// told to run `ljos cloud up`.
275#[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/// Whether a cloud-guard doctor row should print `warn`. A missing file
313/// stays `info`.
314#[must_use]
315pub(crate) fn guard_row_is_warning(state: &str) -> bool {
316    state.contains("pre-push hook")
317}
318
319/// Whether a `cloud age` row is the unset case: no secret was named and
320/// no identity file is on disk. That row is `info`. A file with the wrong
321/// mode, or a named secret that was not written, is a failure.
322#[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/// What `ljos doctor` says about the age identity for scope `cloud`.
328#[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/// The stderr line when a configured MCP command does not exist.
362/// `None` when there is no config, or the command is there.
363#[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/// The one cloud rule: a push from a cloud seat may update only that
372/// seat's named branches and `develop`, and may not force or delete a ref.
373/// There is no refusal file. A machine `ljos cloud up` has not marked is
374/// not a cloud seat, so this says nothing.
375#[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
388/// git's org merge driver. The merged text replaces `ours`.
389///
390/// # Errors
391///
392/// An issue file did not parse, or the result still has conflict markers.
393pub 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/// Hand back `STARTED` claims this machine made whose owner is gone and
398/// whose claim is older than `lease` seconds. A claim is this machine's
399/// when a hold record here names it. Another machine's claim has no record
400/// here, so it is never released from here. `seat` narrows the release to
401/// that seat's claims. No-op unless `LJOS_TRACKER_GIT=push`. The release
402/// is committed and pushed like any other tracker write.
403#[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    /// The PATH the client searches for a bare command when that is not a
467    /// shell's: launchd's, for a macOS app opened from the Dock.
468    launch_path: Option<String>,
469    /// Parse and read errors fail the row. Harness files are skipped when
470    /// they are not the JSON this probe expects (Zed's settings are JSONC).
471    strict: bool,
472}
473
474/// How a harness substitutes variables in a command and its env.PATH.
475///
476/// Codex stores `$HOME` as text. OpenCode substitutes `{env:VAR}` only.
477/// Cursor substitutes `${env:VAR}` and `${userHome}`. Claude Code
478/// substitutes `${VAR}` and `${VAR:-default}` and keeps an unset `${VAR}`
479/// as written. Any other client is read as a shell: `$HOME`, `${HOME}`,
480/// `${env:HOME}`, `${userHome}` and a leading `~/` in a command, and
481/// `$PATH`, `${PATH}` and `${env:PATH}` in env.PATH.
482#[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
562/// Cursor's `mcp.json`, `LJOS_MCP_CONFIG`, and every harness file ljos
563/// writes. One path is checked once. Claude's `mcp add` stores the command
564/// in `~/.claude.json`, which the harness table does not name.
565fn mcp_files() -> Vec<McpFile> {
566    collect_mcp_files(true)
567}
568
569/// `include_user_harness` reads `harnesses.toml`. The seat-key scan leaves
570/// it out: that file is not the MCP config, and a special file there must
571/// not stall a hook.
572fn 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
698/// The working directory and, when it sits inside a checkout, that
699/// checkout's root. A directory that is already gone is left out: the
700/// hook must not stall on it.
701fn 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
745/// The most of an MCP config the seat-key scan reads. A larger file is
746/// not a config this hook will trust.
747const MCP_CONFIG_CAP: u64 = 1024 * 1024;
748
749/// Server keys whose command is this seat's MCP server.
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub(crate) enum SeatMcpKeys {
752    /// No config file was there.
753    Unknown,
754    /// A file that has to parse could not be read.
755    Ambiguous,
756    /// Keys found in the files that were read.
757    Known(BTreeSet<String>),
758}
759
760/// Keys in the seat's MCP config whose command is `ljos-mcp`.
761/// An absent config is [`SeatMcpKeys::Unknown`]. A strict file that is
762/// not readable JSON is [`SeatMcpKeys::Ambiguous`] only when no other
763/// file or plugin install named a key. One unreadable file hides its
764/// own entries and nothing else. Harness files that are not the JSON
765/// or TOML this scan expects are skipped.
766///
767/// A seat key comes from the seat's own files: the home MCP configs,
768/// the harness files, `LJOS_MCP_CONFIG`, and a plugin installed under
769/// the home `.claude` directory. A project `.mcp.json` or
770/// `.cursor/mcp.json`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and a
771/// parent's `--mcp-config` argument do not add a key.
772pub(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
803/// Whether `path` is the seat's own MCP config. Project files and a
804/// config directory named by `CLAUDE_CONFIG_DIR` or `CODEX_HOME` are
805/// left out, unless that path is the home file the harness already reads.
806fn 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    // `~/.claude.json` stores per-project history and grows past the
903    // cap. The seat key is the top-level `mcpServers` value. Read that
904    // and stop, so the history is not pulled in and the cap does not hide
905    // the key.
906    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
966/// The raw JSON of the top-level `mcpServers` value. Reading stops once
967/// that value is in hand, so a history array after it is not consumed.
968fn 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
1179/// `mcp__plugin_<plugin>_<server>__...` for an installed plugin whose
1180/// server command is the seat. `enabledPlugins` must name the plugin
1181/// when that object is present. No settings file means the install counts.
1182fn plugin_seat_keys() -> BTreeSet<String> {
1183    let mut out = BTreeSet::new();
1184    // A plugin install under the home directory is the seat's own.
1185    // CLAUDE_CONFIG_DIR is not: that directory is chosen by the process.
1186    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
1273/// Whether a Cursor payload `command` field launches the seat server.
1274/// The field is one string, so `ljos mcp` and `ljos-plugin ljos-mcp`
1275/// are split on whitespace. An empty field is not a command.
1276pub(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    // The shipped plugin starts the seat as `ljos-plugin ljos-mcp`.
1294    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
1300/// Claude Code's tool segment keeps `[A-Za-z0-9_-]` and turns every other
1301/// character into `_`. `ljos.seat` is the tool `mcp__ljos_seat__...`.
1302/// The raw key is kept as well. Case is not folded.
1303fn 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
1519/// The `command` or `cmd` in the section the harness marker names.
1520fn 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
1535/// The marker's own table, including dotted children such as
1536/// `[mcp_servers.ljos.env]`. A fixed window runs into the next server,
1537/// and `NODE_PATH` used to count as `PATH`.
1538fn 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
1624/// The env.PATH the client hands the server, with this process's PATH
1625/// substituted where the client would substitute its own.
1626fn 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
1707/// Substitute the home tokens this harness actually expands.
1708fn 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
1718/// Cursor's `${env:NAME}` and `${userHome}`. `$HOME`, `${HOME}` and `~/`
1719/// stay text.
1720fn 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
1727/// Claude Code's `${VAR}` and `${VAR:-default}`. An unset `${VAR}` with no
1728/// default stays as written, as do `$HOME`, `~/` and Cursor's forms.
1729fn 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
1752/// Each `${...}` in `text` replaced by what `resolve` gives for its inside,
1753/// or kept as written when it gives nothing.
1754fn 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
1773/// OpenCode substitutes `{env:HOME}` only. `$HOME`, `${HOME}`, `~/` and
1774/// `${env:HOME}` stay text: the docs say shell expressions are not expanded.
1775fn 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
1782/// OpenCode's `{env:NAME}` replaced by `value`. `${env:NAME}` is not
1783/// OpenCode's form and stays text.
1784fn 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
1802/// `$HOME`, `${HOME}`, `${env:HOME}`, `${userHome}` and a leading `~/`.
1803fn 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    // A directory is mode 0755, and `metadata` follows a symlink. Either
1989    // would count as an installed encoder and skip the download.
1990    fs::symlink_metadata(path)
1991        .map(|meta| meta.file_type().is_file() && meta.permissions().mode() & 0o111 != 0)
1992        .unwrap_or(false)
1993}
1994
1995/// Copy `src` beside `dest` and rename it over `dest`. A running binary
1996/// cannot be opened for writing (ETXTBSY), and the `ljos` running cloud up
1997/// is often the one being replaced; a rename leaves it running from the
1998/// old inode.
1999fn 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
2131/// `packset-embed` and the ONNX Runtime it links, from this version's
2132/// release, on a cloud VM only. Elsewhere nothing is downloaded.
2133/// A release without them leaves search lexical. Both sums are checked
2134/// before either archive is unpacked.
2135fn 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    // `tar -xzf` unpacks a symlink, and `is_file` follows it, so the
2199    // install would put that link on PATH and chmod its target. Refuse
2200    // those members, hard links, and paths that leave the tree, in both
2201    // archives, before unpacking either.
2202    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    // A running encoder keeps the inode it opened, so the new files are
2231    // renamed in. chmod and the destination directory happen first: a
2232    // failure after the runtime rename used to leave the new runtime and
2233    // delete the old one with the stage. Both names move aside, then both
2234    // new names move in, and either failure puts the previous pair back.
2235    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
2251/// `symlink_metadata` sees a dangling symlink. `exists` follows links and
2252/// would leave that name in the way of the rename.
2253fn 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
2261/// Stage both names, swap both, and roll both back. The stage directory is
2262/// removed by the caller, so a backup that is still there at return is gone.
2263fn 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        // The new runtime is occupying the live name. Move it back into the
2307        // stage, or delete it, so the previous runtime can return.
2308        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
2316/// A cloud VM. A CI job is not one, so `cloud up` does not download
2317/// the encoder. The same [`runtime`] answers doctor.
2318fn 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
2344/// An asset of this version's release into `to`, from `LJOS_RELEASE_DIR`
2345/// when it is set. `Ok(false)` when the release has no such asset.
2346fn 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
2424/// Clone or update the tracker at `dest`. The flag is false when no clone
2425/// is there afterwards, and the line then says what to set.
2426fn 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
2480/// `text` with the fetch URL shown as written down, and no token in it.
2481fn 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
2493/// Clone `remote` to `dest`. `token` is the credential helper and the URL
2494/// it answers for.
2495fn 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
2696/// One `LJOS_WAKE_<SEAT>_URL` read from the environment.
2697struct WakeVar {
2698    name: String,
2699    mid: String,
2700    url: String,
2701}
2702
2703/// A webhook wake for each `LJOS_WAKE_<SEAT>_URL`, keyed by the variable
2704/// `LJOS_WAKE_<SEAT>_KEY`. The launcher passes the key by name, so it is
2705/// read when the wake fires and never written down. `GROK_BOT` is the seat
2706/// `grok-bot`. The lines name the variables and not the URL.
2707///
2708/// Two variables whose names slug to one seat are refused, and the line
2709/// names both. An entry without `source = "env"` was not written by this
2710/// command and is left in place. Entries this command wrote are written
2711/// again.
2712fn 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/// Whether this machine is a cloud seat. `ljos cloud up` marks it by
2832/// writing the push allowlist. A box that never ran it has no file, so
2833/// finish exports no deeds.
2834#[must_use]
2835pub fn cloud_seat() -> bool {
2836    crate::cloud_hook::allow_path().is_some_and(|path| path.is_file())
2837}
2838
2839/// Whether `ljos cloud up` has marked this machine. The old refusal file
2840/// does not count.
2841#[must_use]
2842pub fn any_cloud_marker() -> bool {
2843    cloud_seat()
2844}
2845
2846/// Delete a refusal file an older `ljos cloud up` wrote. The one push
2847/// rule is not stored in a file.
2848pub 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
2857/// Replace the push allowlist `ljos cloud up` writes. `code` and `tracker`
2858/// are remote URLs. Branch names are the short names (`develop`).
2859///
2860/// # Errors
2861///
2862/// No config home, or the allowlist file cannot be replaced.
2863pub 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
2891/// `LJOS_CLOUD_AGE_SECRET` is the name of another variable. The identity
2892/// itself is never a legal name, so a value pasted into this variable is
2893/// refused without being written or echoed.
2894enum 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
2998/// What is wrong with the named variable, when it cannot be written.
2999/// `None` when it holds an age identity. The value is not returned.
3000fn 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    // A symlink anywhere in the path would make the read, the chmod, and
3075    // the replace land in the directory the link points at. The final
3076    // component is still refused below; this catches a parent.
3077    // A symlink the system owns, such as `/tmp` or `/var`, is already
3078    // replaced above. A link a user can swap is not.
3079    refuse_symlink_parent(path)?;
3080    // Follow a symlink and the identity would be written into the target,
3081    // or the target's mode would change. A link is left to write_private,
3082    // which refuses it.
3083    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
3105/// True when a parent of `path` is a symlink. The final component is not
3106/// looked at. Missing parents are not links.
3107fn 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
3128/// A symlink owned by root, in a directory owned by root that other
3129/// users cannot write. `/tmp` and `/var` on macOS are this. A link in a
3130/// directory the user can write is a swap, and is not.
3131fn 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
3158/// Replace symlink components the system owns with their targets, and
3159/// leave every other component as given. A user link stays a link so the
3160/// caller can refuse it.
3161fn 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
3203/// Open `path` by walking from `/` with `O_NOFOLLOW`, so a symlink in any
3204/// component is not followed. The returned fd is the last component.
3205fn 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        // SAFETY: current is an open directory fd. The name is a NUL-terminated
3248        // byte string with no interior NUL. openat returns a new fd or -1.
3249        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        // SAFETY: fd is newly created, not shared, and was opened with O_CLOEXEC.
3254        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    // Linux returns ENOTDIR, not ELOOP, when O_DIRECTORY meets a symlink
3264    // parent. Name that before opening so the chmod is not the probe.
3265    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
3286/// Write `text` to `path` as mode 0600. A symlink at `path` is refused, so
3287/// the identity is not stored through a link to another file. The temp
3288/// file beside it is made new, mode 0600 from the start, and is not a
3289/// link: a file or link already at that name is refused, not written
3290/// through, so no other user can read the identity even for a moment.
3291fn 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        // The umask may clear bits of the mode asked for at creation.
3319        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        // Ubuntu's skel bashrc splits the test and the return:
3343        // `case $- in` / `*i*) ;;` / `*) return;;` / `esac`, and an
3344        // `if [ -z "$PS1" ]; then` / `return` / `fi` splits them the same
3345        // way. A one-line search appends PATH after that return, so a
3346        // non-interactive shell never sees it.
3347        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
3359/// `case $- in` or `case "$PS1" in`, the interactive test on its own line.
3360fn 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
3370/// `if [ -z "$PS1" ]; then` or `if [[ $- != *i* ]]`, an `if` that tests
3371/// for an interactive shell.
3372fn if_tests_shell(trimmed: &str) -> bool {
3373    trimmed.starts_with("if ") && (trimmed.contains("$PS1") || trimmed.contains("$-"))
3374}
3375
3376/// Whether the block returns before the `esac` or `fi` that closes it. A
3377/// `case "$TERM"` does not.
3378fn 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
3436/// Put `bin_dir` first on this process's PATH and drop a later copy.
3437/// Presence is not enough: an earlier directory hides this install.
3438fn 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
3486/// Whether a hold record on this machine names the claim: `Some(true)`
3487/// when one of them names a runner that is alive, `Some(false)` when every
3488/// one names a runner that is gone, and `None` when none names it. `None`
3489/// is a claim another machine made.
3490fn 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        // A hold that names no issue cannot say which claims it held, so
3509        // once its runner is gone it releases none of them.
3510        if naming == HoldNames::Issue {
3511            found = Some(false);
3512        }
3513    }
3514    found
3515}
3516
3517#[derive(Debug, PartialEq, Eq)]
3518enum HoldNames {
3519    /// The record names this issue.
3520    Issue,
3521    /// A record named by the seat itself with a blank issue line: it
3522    /// keeps every claim of that seat while its runner lives.
3523    AnyOfSeat,
3524    Other,
3525}
3526
3527/// Whether this hold record is the one `ljos claim` wrote for `issue`.
3528///
3529/// The filename is the claim-graph actor, a hash, while the tracker stores
3530/// the seat under `:CLAIMED_BY:`. The record's first line is that seat and
3531/// its sixth line is the issue. A filename that is already the seat still
3532/// matches, which is how a hand-written hold keeps its claim. A live hold
3533/// for another issue does not keep this one.
3534fn 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
3597/// `ljos cloud up` on a cloud VM, codespace, or CI job. A desktop Cursor
3598/// file is onboarded with the cursor harness; the api socket on a cloud
3599/// image is not enough, because doctor is often aimed at another home.
3600fn 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    // A desktop terminal sets CURSOR_AGENT=1 and nothing else. The api
3614    // socket is not enough either: doctor is often aimed at another home.
3615    if rt.cursor_harness && rt.cloud_agent {
3616        return true;
3617    }
3618    cloud_env_file_seat()
3619}
3620
3621/// Where this process is running. [`cloud_vm`], [`on_cloud_vm`] and
3622/// [`harness_kind`] all read this, so a CI value cannot count in one and
3623/// not the other.
3624fn 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    // CURSOR_AGENT=1 is the desktop agent terminal. The socket path and
3630    // the conversation id are set on a cloud VM and are not that flag.
3631    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    // By value, as each system documents it. Presence of `false` is not a job.
3640    // GitHub Actions: GITHUB_ACTIONS=true. GitLab: GITLAB_CI=true and CI=true.
3641    // Buildkite: BUILDKITE=true. Azure Pipelines: TF_BUILD=True.
3642    // CircleCI: CIRCLECI=true. Jenkins: JENKINS_URL is the controller URL.
3643    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
3671/// The value `true`, ignoring case and surrounding space.
3672fn 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
3678/// `cloud up` records `cursor-ID`, `ci-ID`, or `codespace-ID` in the env
3679/// file. A later shell without the agent variables still is that VM.
3680fn 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    // A GitHub Actions job is one run, including when a cloud-agent id is
3698    // also present in the environment. The run id is the suffix.
3699    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    /// tar's verbose type character: `-` file, `d` directory, `l` symlink.
3764    kind: u8,
3765    name: Vec<u8>,
3766}
3767
3768/// Members of one encoder archive. A listing tar will not parse is refused,
3769/// so a hostile header cannot be unpacked on the strength of a later stat.
3770fn 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
3823/// `tar -tv`. GNU tar is asked for shell quotes. A tar that rejects those
3824/// flags, such as the one shipped on macOS, is asked again with `-tvzf`
3825/// only. A listing that does not parse is refused.
3826fn 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
3863/// `tar -tv`, either shell-quoted or the short listing. Names may contain
3864/// newlines in the quoted form, so those records are split on the quotes.
3865fn 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
3910/// A `tar -tv` line without shell quotes. The name follows the date.
3911/// A symlink's target after ` -> ` is not part of the name.
3912fn 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
3960/// One shell-always single-quoted word. `'\''` is a literal quote.
3961fn 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
3987/// What the unpack left on disk. `symlink_metadata` does not follow a link,
3988/// so a link tar created is still a link here.
3989fn 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    /// The short listing has no shell quotes. A symlink is still named,
4092    /// and its target is not part of the member path.
4093    #[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    /// A Dock app looks for a bare `ljos-mcp` on its own PATH, not on this
4159    /// process's PATH or a login shell's.
4160    #[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    /// O_NOFOLLOW on the last component still follows a symlink parent, so
4220    /// chmod would change the file in the directory the link points at.
4221    #[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}