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 = Command::new("git")
2517        .current_dir(parent)
2518        .args(crate::tracker_attach::clone_config(url, helper))
2519        .args(["clone", "--quiet", url, &name])
2520        .env("GIT_TERMINAL_PROMPT", "0")
2521        .env_remove("GIT_ASKPASS")
2522        .stdin(std::process::Stdio::null())
2523        .output()
2524        .context("git clone")?;
2525    if !out.status.success() {
2526        let said = String::from_utf8_lossy(&out.stderr);
2527        bail!("tracker: clone of {shown} failed: {}", said.trim());
2528    }
2529    if let Some(helper) = helper {
2530        crate::tracker_attach::configure(dest, Some(helper), url)?;
2531    }
2532    ensure_identity(dest, seat)?;
2533    let from = &remote.from;
2534    match crate::tracker_push::publish(dest) {
2535        Ok(()) => Ok(format!(
2536            "tracker: cloned {shown} to {} (from {from})",
2537            dest.display()
2538        )),
2539        Err(e) => Ok(format!(
2540            "tracker: cloned {shown} to {} (from {from}); push queued: {}",
2541            dest.display(),
2542            hide(&e, remote)
2543        )),
2544    }
2545}
2546
2547fn ensure_identity(dir: &Path, seat: &str) -> Result<()> {
2548    let email = git_local(dir, "user.email");
2549    if email.map(|v| v.is_empty()).unwrap_or(true) {
2550        git_set(
2551            dir,
2552            "user.email",
2553            &format!("{seat}@users.noreply.github.com"),
2554        )?;
2555        git_set(dir, "user.name", seat)?;
2556    }
2557    git_set(dir, "commit.gpgsign", "false")?;
2558    git_set(dir, "pull.rebase", "true")?;
2559    Ok(())
2560}
2561
2562fn git_local(dir: &Path, key: &str) -> Option<String> {
2563    let out = Command::new("git")
2564        .arg("-C")
2565        .arg(dir)
2566        .args(["config", "--local", "--get", key])
2567        .stdin(std::process::Stdio::null())
2568        .output()
2569        .ok()?;
2570    if !out.status.success() {
2571        return None;
2572    }
2573    let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
2574    (!value.is_empty()).then_some(value)
2575}
2576
2577fn git_set(dir: &Path, key: &str, value: &str) -> Result<()> {
2578    let out = Command::new("git")
2579        .arg("-C")
2580        .arg(dir)
2581        .args(["config", "--local", key, value])
2582        .stdin(std::process::Stdio::null())
2583        .output()
2584        .with_context(|| format!("git config {key}"))?;
2585    if out.status.success() {
2586        Ok(())
2587    } else {
2588        bail!(
2589            "git config {key}: {}",
2590            String::from_utf8_lossy(&out.stderr).trim()
2591        )
2592    }
2593}
2594
2595fn is_work_tree(dir: &Path) -> bool {
2596    let Ok(out) = Command::new("git")
2597        .arg("-C")
2598        .arg(dir)
2599        .args(["rev-parse", "--is-inside-work-tree"])
2600        .stdin(std::process::Stdio::null())
2601        .output()
2602    else {
2603        return false;
2604    };
2605    out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true"
2606}
2607
2608fn write_mcp(
2609    home: &Path,
2610    server: &Path,
2611    plan: &crate::ServerCommandPlan,
2612    seat: &str,
2613    key: &Path,
2614    tracker: Option<&Path>,
2615    remote: Option<&str>,
2616) -> Result<String> {
2617    if !server.is_file() {
2618        bail!(
2619            "mcp: {} does not exist, so the harness was not pointed at it",
2620            server.display()
2621        );
2622    }
2623    let mut env = serde_json::Map::new();
2624    env.insert("LJOS_SEAT".into(), serde_json::json!(seat));
2625    env.insert("VISSUE_AGENT".into(), serde_json::json!(seat));
2626    env.insert(crate::sync::SYNC_HOST_KEY.into(), serde_json::json!(seat));
2627    env.insert(
2628        "LJOS_SEAT_KEY".into(),
2629        serde_json::json!(key.display().to_string()),
2630    );
2631    if std::env::var("DEEDAR_HOST_SIGNING_KEY").ok().as_deref() != Some("off") {
2632        env.insert(
2633            "DEEDAR_HOST_SIGNING_KEY".into(),
2634            serde_json::json!(key.display().to_string()),
2635        );
2636    }
2637    if let Some(tracker) = tracker {
2638        env.insert("LJOS_TRACKER_GIT".into(), serde_json::json!("push"));
2639        env.insert(
2640            "VISSUE_ROOT".into(),
2641            serde_json::json!(tracker.display().to_string()),
2642        );
2643        env.insert(
2644            "ISSUE_ROOT".into(),
2645            serde_json::json!(tracker.display().to_string()),
2646        );
2647    }
2648    if let Some(remote) = remote {
2649        env.insert("LJOS_TRACKER_REMOTE".into(), serde_json::json!(remote));
2650    }
2651    let command = plan.command.as_str();
2652    let entry = serde_json::json!({
2653        "type": "stdio",
2654        "command": command,
2655        "args": [],
2656        "env": env,
2657    });
2658    let path = home.join(".cursor/mcp.json");
2659    crate::set_json_entry(&path, "/mcpServers/ljos", &entry)?;
2660    let mut line = format!("mcp: {} starts {command}", path.display());
2661    if let Some(note) = &plan.note {
2662        line.push_str("; ");
2663        line.push_str(note);
2664    }
2665    Ok(line)
2666}
2667
2668fn write_env(path: &Path, pairs: &[(&str, String)]) -> Result<()> {
2669    let text = fs::read_to_string(path).unwrap_or_default();
2670    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
2671    for (key, value) in pairs {
2672        if value.contains('\n') {
2673            bail!("{key} contains a newline");
2674        }
2675        let row = format!("{key}={value}");
2676        if let Some(existing) = lines.iter_mut().find(|line| {
2677            let trimmed = line.trim_start();
2678            trimmed.starts_with(&format!("{key}=")) || trimmed.starts_with(&format!("{key} ="))
2679        }) {
2680            *existing = row;
2681        } else {
2682            lines.push(row);
2683        }
2684    }
2685    let mut out = lines.join("\n");
2686    if !out.ends_with('\n') {
2687        out.push('\n');
2688    }
2689    if let Some(parent) = path.parent() {
2690        fs::create_dir_all(parent)?;
2691    }
2692    fs::write(path, out).with_context(|| format!("write {}", path.display()))?;
2693    Ok(())
2694}
2695
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    // A symlink anywhere in the path would make the read, the chmod, and
3073    // the replace land in the directory the link points at. The final
3074    // component is still refused below; this catches a parent.
3075    refuse_symlink_parent(path)?;
3076    // Follow a symlink and the identity would be written into the target,
3077    // or the target's mode would change. A link is left to write_private,
3078    // which refuses it.
3079    let regular = fs::symlink_metadata(path)
3080        .ok()
3081        .is_some_and(|meta| meta.file_type().is_file());
3082    if regular && fs::read_to_string(path).ok().as_deref() == Some(text) {
3083        if file_mode(path) == Some(0o600) {
3084            return Ok(Install::Same);
3085        }
3086        set_mode_0600(path)?;
3087        return Ok(Install::Mode);
3088    }
3089    write_private(path, text)?;
3090    Ok(Install::Wrote)
3091}
3092
3093fn file_mode(path: &Path) -> Option<u32> {
3094    use std::os::unix::fs::PermissionsExt;
3095    fs::symlink_metadata(path)
3096        .ok()
3097        .filter(|meta| meta.file_type().is_file())
3098        .map(|meta| meta.permissions().mode() & 0o777)
3099}
3100
3101/// True when a parent of `path` is a symlink. The final component is not
3102/// looked at. Missing parents are not links.
3103fn ancestor_is_symlink(path: &Path) -> bool {
3104    let Some(parent) = path.parent() else {
3105        return false;
3106    };
3107    let mut walked = PathBuf::new();
3108    for component in parent.components() {
3109        match component {
3110            std::path::Component::RootDir => walked.push(component),
3111            std::path::Component::CurDir => {}
3112            std::path::Component::Normal(name) => {
3113                walked.push(name);
3114                if fs::symlink_metadata(&walked).is_ok_and(|meta| meta.file_type().is_symlink()) {
3115                    return true;
3116                }
3117            }
3118            _ => return true,
3119        }
3120    }
3121    false
3122}
3123
3124fn refuse_symlink_parent(path: &Path) -> Result<()> {
3125    if ancestor_is_symlink(path) {
3126        bail!(
3127            "cloud up: {} is reached through a symlink, so the scope cloud identity was not written",
3128            path.display()
3129        );
3130    }
3131    Ok(())
3132}
3133
3134/// Open `path` by walking from `/` with `O_NOFOLLOW`, so a symlink in any
3135/// component is not followed. The returned fd is the last component.
3136fn open_nofollow(path: &Path) -> std::io::Result<fs::File> {
3137    use std::os::fd::{AsRawFd, FromRawFd};
3138    use std::os::unix::ffi::OsStrExt;
3139    use std::os::unix::fs::OpenOptionsExt;
3140    let absolute;
3141    let path = if path.is_absolute() {
3142        path
3143    } else {
3144        absolute = std::env::current_dir()?.join(path);
3145        absolute.as_path()
3146    };
3147    let mut names = Vec::new();
3148    for component in path.components() {
3149        match component {
3150            std::path::Component::RootDir | std::path::Component::CurDir => {}
3151            std::path::Component::Normal(name) => names.push(name),
3152            _ => {
3153                return Err(std::io::Error::new(
3154                    std::io::ErrorKind::InvalidInput,
3155                    "path is not direct",
3156                ));
3157            }
3158        }
3159    }
3160    let mut current = fs::OpenOptions::new()
3161        .read(true)
3162        .custom_flags(libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
3163        .open("/")?;
3164    for (index, name) in names.iter().enumerate() {
3165        let last = index + 1 == names.len();
3166        let mut bytes = name.as_bytes().to_vec();
3167        if bytes.contains(&0) {
3168            return Err(std::io::Error::new(
3169                std::io::ErrorKind::InvalidInput,
3170                "nul in path",
3171            ));
3172        }
3173        bytes.push(0);
3174        let mut flags = libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_RDONLY;
3175        if !last {
3176            flags |= libc::O_DIRECTORY;
3177        }
3178        // SAFETY: current is an open directory fd. The name is a NUL-terminated
3179        // byte string with no interior NUL. openat returns a new fd or -1.
3180        let fd = unsafe { libc::openat(current.as_raw_fd(), bytes.as_ptr().cast(), flags) };
3181        if fd < 0 {
3182            return Err(std::io::Error::last_os_error());
3183        }
3184        // SAFETY: fd is newly created, not shared, and was opened with O_CLOEXEC.
3185        current = unsafe { fs::File::from_raw_fd(fd) };
3186    }
3187    Ok(current)
3188}
3189
3190fn set_mode_0600(path: &Path) -> Result<()> {
3191    use std::os::unix::fs::PermissionsExt;
3192    // Linux returns ENOTDIR, not ELOOP, when O_DIRECTORY meets a symlink
3193    // parent. Name that before opening so the chmod is not the probe.
3194    let linked = ancestor_is_symlink(path)
3195        || fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink());
3196    if linked {
3197        bail!("mode 0600 {}: path is a symlink", path.display());
3198    }
3199    let file = match open_nofollow(path) {
3200        Ok(file) => file,
3201        Err(err) if matches!(err.raw_os_error(), Some(libc::ELOOP) | Some(libc::ENOTDIR)) => {
3202            bail!("mode 0600 {}: path is a symlink", path.display());
3203        }
3204        Err(err) => {
3205            return Err(err).with_context(|| format!("mode 0600 {}", path.display()));
3206        }
3207    };
3208    if !file.metadata().is_ok_and(|meta| meta.file_type().is_file()) {
3209        bail!("mode 0600 {}: target is not a file", path.display());
3210    }
3211    file.set_permissions(fs::Permissions::from_mode(0o600))
3212        .with_context(|| format!("mode 0600 {}", path.display()))
3213}
3214
3215/// Write `text` to `path` as mode 0600. A symlink at `path` is refused, so
3216/// the identity is not stored through a link to another file. The temp
3217/// file beside it is made new, mode 0600 from the start, and is not a
3218/// link: a file or link already at that name is refused, not written
3219/// through, so no other user can read the identity even for a moment.
3220fn write_private(path: &Path, text: &str) -> Result<()> {
3221    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
3222    refuse_symlink_parent(path)?;
3223    if let Some(dir) = path.parent() {
3224        fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
3225    }
3226    if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
3227        bail!(
3228            "cloud up: {} is a symlink, so the scope cloud identity was not written; remove it and run `ljos cloud up` again",
3229            path.display()
3230        );
3231    }
3232    let name = path
3233        .file_name()
3234        .and_then(|n| n.to_str())
3235        .unwrap_or("cloud.age");
3236    let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
3237    let mut file = fs::OpenOptions::new()
3238        .write(true)
3239        .create_new(true)
3240        .mode(0o600)
3241        .custom_flags(libc::O_NOFOLLOW)
3242        .open(&tmp)
3243        .with_context(|| format!("create {}", tmp.display()))?;
3244    let write = (|| -> Result<()> {
3245        // The umask may clear bits of the mode asked for at creation.
3246        file.set_permissions(fs::Permissions::from_mode(0o600))?;
3247        file.write_all(text.as_bytes())?;
3248        file.sync_all()?;
3249        fs::rename(&tmp, path).with_context(|| format!("install {}", path.display()))?;
3250        Ok(())
3251    })();
3252    if write.is_err() {
3253        let _ = fs::remove_file(&tmp);
3254    }
3255    write
3256}
3257
3258fn path_export_line(bin_dir: &Path) -> String {
3259    format!("export PATH=\"{}:$PATH\"", bin_dir.display())
3260}
3261
3262fn first_interactive_guard(text: &str) -> Option<usize> {
3263    let lines: Vec<&str> = text.split_inclusive('\n').collect();
3264    let mut at = 0usize;
3265    for (i, line) in lines.iter().enumerate() {
3266        let trimmed = line.trim();
3267        let one_line =
3268            (trimmed.contains("$PS1") || trimmed.contains("$-")) && trimmed.contains("return");
3269        // Ubuntu's skel bashrc splits the test and the return:
3270        // `case $- in` / `*i*) ;;` / `*) return;;` / `esac`, and an
3271        // `if [ -z "$PS1" ]; then` / `return` / `fi` splits them the same
3272        // way. A one-line search appends PATH after that return, so a
3273        // non-interactive shell never sees it.
3274        let rest = &lines[i + 1..];
3275        if one_line
3276            || (case_tests_shell(trimmed) && block_returns(rest, "esac"))
3277            || (if_tests_shell(trimmed) && block_returns(rest, "fi"))
3278        {
3279            return Some(at);
3280        }
3281        at += line.len();
3282    }
3283    None
3284}
3285
3286/// `case $- in` or `case "$PS1" in`, the interactive test on its own line.
3287fn case_tests_shell(trimmed: &str) -> bool {
3288    let Some(rest) = trimmed.strip_prefix("case") else {
3289        return false;
3290    };
3291    let rest = rest.trim_start();
3292    let word = rest.split_whitespace().next().unwrap_or("");
3293    let word = word.trim_matches(|c| c == '"' || c == '\'');
3294    word == "$-" || word == "$PS1"
3295}
3296
3297/// `if [ -z "$PS1" ]; then` or `if [[ $- != *i* ]]`, an `if` that tests
3298/// for an interactive shell.
3299fn if_tests_shell(trimmed: &str) -> bool {
3300    trimmed.starts_with("if ") && (trimmed.contains("$PS1") || trimmed.contains("$-"))
3301}
3302
3303/// Whether the block returns before the `esac` or `fi` that closes it. A
3304/// `case "$TERM"` does not.
3305fn block_returns(lines: &[&str], end: &str) -> bool {
3306    for line in lines {
3307        let trimmed = line.trim();
3308        let first = trimmed.split([';', ' ', '\t']).next().unwrap_or("");
3309        if first == end {
3310            return false;
3311        }
3312        if trimmed.contains("return") {
3313            return true;
3314        }
3315    }
3316    false
3317}
3318
3319fn ensure_path_file(path: &Path, bin_dir: &Path, before_interactive_guard: bool) -> Result<String> {
3320    let have = fs::read_to_string(path).unwrap_or_default();
3321    let line = path_export_line(bin_dir);
3322    if have.lines().any(|l| l.trim() == line) {
3323        return Ok(format!(
3324            "path: {} already names {}",
3325            path.display(),
3326            bin_dir.display()
3327        ));
3328    }
3329    let mut out = have;
3330    if before_interactive_guard {
3331        if let Some(at) = first_interactive_guard(&out) {
3332            let mut next = String::with_capacity(out.len() + line.len() + 1);
3333            next.push_str(&out[..at]);
3334            next.push_str(&line);
3335            next.push('\n');
3336            next.push_str(&out[at..]);
3337            out = next;
3338        } else {
3339            if !out.is_empty() && !out.ends_with('\n') {
3340                out.push('\n');
3341            }
3342            out.push_str(&line);
3343            out.push('\n');
3344        }
3345    } else {
3346        if !out.is_empty() && !out.ends_with('\n') {
3347            out.push('\n');
3348        }
3349        out.push_str(&line);
3350        out.push('\n');
3351    }
3352    if let Some(parent) = path.parent() {
3353        fs::create_dir_all(parent)?;
3354    }
3355    fs::write(path, out).with_context(|| format!("write {}", path.display()))?;
3356    Ok(format!(
3357        "path: {} adds {}",
3358        path.display(),
3359        bin_dir.display()
3360    ))
3361}
3362
3363/// Put `bin_dir` first on this process's PATH and drop a later copy.
3364/// Presence is not enough: an earlier directory hides this install.
3365fn ensure_process_path(bin_dir: &Path) -> String {
3366    let dir = bin_dir.display().to_string();
3367    let current = std::env::var("PATH").unwrap_or_default();
3368    let entries: Vec<&str> = current
3369        .split(':')
3370        .filter(|entry| !entry.is_empty())
3371        .collect();
3372    if entries.first() == Some(&dir.as_str()) {
3373        return format!("path: PATH already starts with {dir}");
3374    }
3375    let mut next = dir.clone();
3376    for entry in entries.into_iter().filter(|entry| *entry != dir) {
3377        next.push(':');
3378        next.push_str(entry);
3379    }
3380    crate::with_env_mutation(|| std::env::set_var("PATH", &next));
3381    format!("path: PATH starts with {dir}")
3382}
3383
3384fn release_started(id: &str, holder: &str) -> Result<Option<String>, String> {
3385    let layout = vissue_core::Layout::resolve(None, None).map_err(|e| e.to_string())?;
3386    let found = vissue_core::store::find_by_id(&layout, id).map_err(|e| e.to_string())?;
3387    let Some((_, path, project)) = found else {
3388        return Ok(None);
3389    };
3390    vissue_core::store::with_issues_lock(&path, || {
3391        let mut doc = vissue_core::IssueDoc::parse_file(&project, &path)?;
3392        let Some(heading) = doc.headings.iter_mut().find(|h| h.id == id) else {
3393            return Ok(None);
3394        };
3395        let Some(who) = heading.claimed_by().map(str::to_string) else {
3396            return Ok(None);
3397        };
3398        if !crate::same_holder(&who, holder) {
3399            return Ok(None);
3400        }
3401        heading.release_claim();
3402        if heading.state == "STARTED" {
3403            heading.record_state_change("TODO");
3404        }
3405        doc.write()?;
3406        Ok(Some(format!(
3407            "tracker: {id} no longer claimed by {who}; STARTED handed back"
3408        )))
3409    })
3410    .map_err(|e| e.to_string())
3411}
3412
3413/// Whether a hold record on this machine names the claim: `Some(true)`
3414/// when one of them names a runner that is alive, `Some(false)` when every
3415/// one names a runner that is gone, and `None` when none names it. `None`
3416/// is a claim another machine made.
3417fn local_hold(assignee: &str, issue: &str) -> Option<bool> {
3418    let entries = fs::read_dir(crate::runtime_dir()).ok()?;
3419    let mut found = None;
3420    for ent in entries.flatten() {
3421        let name = ent.file_name().to_string_lossy().into_owned();
3422        let Some(actor) = name.strip_prefix("hold-") else {
3423            continue;
3424        };
3425        let naming = hold_names_claim(actor, assignee, issue);
3426        if naming == HoldNames::Other {
3427            continue;
3428        }
3429        let Some(hold) = crate::read_hold(actor) else {
3430            continue;
3431        };
3432        if crate::hold_alive(&hold) {
3433            return Some(true);
3434        }
3435        // A hold that names no issue cannot say which claims it held, so
3436        // once its runner is gone it releases none of them.
3437        if naming == HoldNames::Issue {
3438            found = Some(false);
3439        }
3440    }
3441    found
3442}
3443
3444#[derive(Debug, PartialEq, Eq)]
3445enum HoldNames {
3446    /// The record names this issue.
3447    Issue,
3448    /// A record named by the seat itself with a blank issue line: it
3449    /// keeps every claim of that seat while its runner lives.
3450    AnyOfSeat,
3451    Other,
3452}
3453
3454/// Whether this hold record is the one `ljos claim` wrote for `issue`.
3455///
3456/// The filename is the claim-graph actor, a hash, while the tracker stores
3457/// the seat under `:CLAIMED_BY:`. The record's first line is that seat and
3458/// its sixth line is the issue. A filename that is already the seat still
3459/// matches, which is how a hand-written hold keeps its claim. A live hold
3460/// for another issue does not keep this one.
3461fn hold_names_claim(actor: &str, assignee: &str, issue: &str) -> HoldNames {
3462    let path = crate::runtime_dir().join(format!("hold-{actor}"));
3463    let Ok(text) = fs::read_to_string(path) else {
3464        return HoldNames::Other;
3465    };
3466    let lines: Vec<&str> = text.lines().map(str::trim).collect();
3467    let record_assignee = lines.first().copied().unwrap_or("");
3468    let node = lines.get(5).copied().unwrap_or("");
3469    let name_ok =
3470        crate::same_holder(actor, assignee) || crate::same_holder(record_assignee, assignee);
3471    if !name_ok {
3472        return HoldNames::Other;
3473    }
3474    if node.is_empty() {
3475        return if crate::same_holder(actor, assignee) {
3476            HoldNames::AnyOfSeat
3477        } else {
3478            HoldNames::Other
3479        };
3480    }
3481    if node == issue {
3482        HoldNames::Issue
3483    } else {
3484        HoldNames::Other
3485    }
3486}
3487
3488fn bin_dir(home: &Path) -> PathBuf {
3489    std::env::var_os("LJOS_BIN_DIR")
3490        .filter(|p| !p.is_empty())
3491        .map(PathBuf::from)
3492        .unwrap_or_else(|| home.join(".local/bin"))
3493}
3494
3495fn tracker_dir(home: &Path) -> PathBuf {
3496    std::env::var_os("LJOS_TRACKER_DIR")
3497        .filter(|p| !p.is_empty())
3498        .map(PathBuf::from)
3499        .unwrap_or_else(|| home.join(".local/share/ljos/tracker"))
3500}
3501
3502fn seat_matches_file() -> bool {
3503    let Some(file) = env_file_value("LJOS_SEAT") else {
3504        return false;
3505    };
3506    std::env::var("LJOS_SEAT").ok().as_deref() == Some(file.as_str())
3507}
3508
3509fn env_file_value(key: &str) -> Option<String> {
3510    let home = crate::home().ok()?;
3511    let text = fs::read_to_string(home.join(".config/ljos/env")).ok()?;
3512    for line in text.lines() {
3513        let line = line.trim();
3514        let Some((k, v)) = line.split_once('=') else {
3515            continue;
3516        };
3517        if k.trim() == key {
3518            return Some(v.trim().to_string());
3519        }
3520    }
3521    None
3522}
3523
3524/// `ljos cloud up` on a cloud VM, codespace, or CI job. A desktop Cursor
3525/// file is onboarded with the cursor harness; the api socket on a cloud
3526/// image is not enough, because doctor is often aimed at another home.
3527fn cursor_mcp_advice() -> String {
3528    if on_cloud_vm() {
3529        "ljos cloud up".into()
3530    } else {
3531        "ljos onboard --harness cursor".into()
3532    }
3533}
3534
3535fn on_cloud_vm() -> bool {
3536    let rt = runtime();
3537    if agent_id().is_some() || rt.ci || rt.codespace {
3538        return true;
3539    }
3540    // A desktop terminal sets CURSOR_AGENT=1 and nothing else. The api
3541    // socket is not enough either: doctor is often aimed at another home.
3542    if rt.cursor_harness && rt.cloud_agent {
3543        return true;
3544    }
3545    cloud_env_file_seat()
3546}
3547
3548/// Where this process is running. [`cloud_vm`], [`on_cloud_vm`] and
3549/// [`harness_kind`] all read this, so a CI value cannot count in one and
3550/// not the other.
3551fn runtime() -> Runtime {
3552    let github_actions = env_is_true("GITHUB_ACTIONS");
3553    let cloud_id =
3554        nonempty_var("CURSOR_AGENT_ID").is_some() || nonempty_var("CLOUD_AGENT_ID").is_some();
3555    let api_socket = Path::new("/run/cursor/api.sock").exists();
3556    // CURSOR_AGENT=1 is the desktop agent terminal. The socket path and
3557    // the conversation id are set on a cloud VM and are not that flag.
3558    let cloud_agent = cloud_id
3559        || nonempty_var("CURSOR_AGENT_SOCKET").is_some()
3560        || nonempty_var("CURSOR_CONVERSATION_ID").is_some();
3561    let cursor_harness = cloud_agent
3562        || api_socket
3563        || std::env::var_os("CURSOR_AGENT").is_some()
3564        || std::env::var_os("CURSOR_AGENT_ID").is_some();
3565    let codespace = env_is_true("CODESPACES") || nonempty_var("CODESPACE_NAME").is_some();
3566    // By value, as each system documents it. Presence of `false` is not a job.
3567    // GitHub Actions: GITHUB_ACTIONS=true. GitLab: GITLAB_CI=true and CI=true.
3568    // Buildkite: BUILDKITE=true. Azure Pipelines: TF_BUILD=True.
3569    // CircleCI: CIRCLECI=true. Jenkins: JENKINS_URL is the controller URL.
3570    let ci = github_actions
3571        || env_is_true("GITLAB_CI")
3572        || env_is_true("CI")
3573        || env_is_true("BUILDKITE")
3574        || env_is_true("TF_BUILD")
3575        || env_is_true("CIRCLECI")
3576        || nonempty_var("JENKINS_URL").is_some();
3577    Runtime {
3578        github_actions,
3579        ci,
3580        codespace,
3581        cloud_id,
3582        api_socket,
3583        cursor_harness,
3584        cloud_agent,
3585    }
3586}
3587
3588struct Runtime {
3589    github_actions: bool,
3590    ci: bool,
3591    codespace: bool,
3592    cloud_id: bool,
3593    api_socket: bool,
3594    cursor_harness: bool,
3595    cloud_agent: bool,
3596}
3597
3598/// The value `true`, ignoring case and surrounding space.
3599fn env_is_true(key: &str) -> bool {
3600    std::env::var(key)
3601        .ok()
3602        .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
3603}
3604
3605/// `cloud up` records `cursor-ID`, `ci-ID`, or `codespace-ID` in the env
3606/// file. A later shell without the agent variables still is that VM.
3607fn cloud_env_file_seat() -> bool {
3608    let Some(seat) = env_file_value("LJOS_SEAT") else {
3609        return false;
3610    };
3611    ["cursor-", "ci-", "codespace-"]
3612        .iter()
3613        .any(|prefix| seat.starts_with(prefix))
3614}
3615
3616fn nonempty_var(key: &str) -> Option<String> {
3617    std::env::var(key)
3618        .ok()
3619        .map(|v| v.trim().to_string())
3620        .filter(|v| !v.is_empty())
3621}
3622
3623fn agent_id() -> Option<String> {
3624    // A GitHub Actions job is one run, including when a cloud-agent id is
3625    // also present in the environment. The run id is the suffix.
3626    if github_actions() {
3627        if let Some(id) = id_slug("GITHUB_RUN_ID") {
3628            return Some(id);
3629        }
3630    }
3631    for key in [
3632        "CURSOR_AGENT_ID",
3633        "CURSOR_AGENT",
3634        "CLOUD_AGENT_ID",
3635        "AGENT_ID",
3636        "CURSOR_CONVERSATION_ID",
3637        "CODESPACE_NAME",
3638        "GITHUB_RUN_ID",
3639    ] {
3640        if let Some(id) = id_slug(key) {
3641            return Some(id);
3642        }
3643    }
3644    None
3645}
3646
3647fn github_actions() -> bool {
3648    runtime().github_actions
3649}
3650
3651fn id_slug(key: &str) -> Option<String> {
3652    run_slug(&nonempty_var(key)?)
3653}
3654
3655fn run_slug(id: &str) -> Option<String> {
3656    let id = id.trim();
3657    if matches!(id, "0" | "1" | "true" | "false") {
3658        return None;
3659    }
3660    let slug = crate::seat_slug(id);
3661    if slug.is_empty() || slug == "runner" {
3662        return None;
3663    }
3664    Some(slug.chars().take(16).collect())
3665}
3666
3667fn harness_kind() -> &'static str {
3668    let rt = runtime();
3669    if rt.cursor_harness {
3670        "cursor"
3671    } else if rt.codespace {
3672        "codespace"
3673    } else if rt.ci {
3674        "ci"
3675    } else {
3676        "vm"
3677    }
3678}
3679
3680fn hostname() -> String {
3681    fs::read_to_string("/etc/hostname")
3682        .ok()
3683        .map(|s| s.trim().to_string())
3684        .filter(|s| !s.is_empty())
3685        .unwrap_or_else(|| "vm".into())
3686}
3687
3688#[derive(Debug)]
3689struct ArchiveMember {
3690    /// tar's verbose type character: `-` file, `d` directory, `l` symlink.
3691    kind: u8,
3692    name: Vec<u8>,
3693}
3694
3695/// Members of one encoder archive. A listing tar will not parse is refused,
3696/// so a hostile header cannot be unpacked on the strength of a later stat.
3697fn refuse_unsafe_archive(file: &Path) -> Result<()> {
3698    let out = Command::new("tar")
3699        .args(["--full-time", "--quoting-style=shell-always", "-tvzf"])
3700        .arg(file)
3701        .stdin(std::process::Stdio::null())
3702        .env("LC_ALL", "C")
3703        .output()
3704        .context("tar")?;
3705    if !out.status.success() {
3706        bail!(
3707            "tar {}: {}; nothing was installed",
3708            file.display(),
3709            String::from_utf8_lossy(&out.stderr).trim()
3710        );
3711    }
3712    let members = match parse_tar_listing(&out.stdout) {
3713        Ok(members) => members,
3714        Err(err) => bail!("tar {}: {err}; nothing was installed", file.display()),
3715    };
3716    for member in members {
3717        let shown = String::from_utf8_lossy(&member.name);
3718        if member.name.is_empty() || member.name.contains(&0) {
3719            bail!(
3720                "encoder archive {}: a member path is empty; nothing was installed",
3721                file.display()
3722            );
3723        }
3724        if !archive_member_path_ok(&member.name) {
3725            bail!(
3726                "encoder archive {}: member {shown} has an absolute path or '..'; nothing was installed",
3727                file.display()
3728            );
3729        }
3730        match member.kind {
3731            b'-' | b'd' => {}
3732            b'l' => bail!(
3733                "encoder archive {}: member {shown} is a symlink; nothing was installed",
3734                file.display()
3735            ),
3736            b'h' => bail!(
3737                "encoder archive {}: member {shown} is a hard link; nothing was installed",
3738                file.display()
3739            ),
3740            _ => bail!(
3741                "encoder archive {}: member {shown} is not a regular file; nothing was installed",
3742                file.display()
3743            ),
3744        }
3745    }
3746    Ok(())
3747}
3748
3749fn archive_member_path_ok(name: &[u8]) -> bool {
3750    use std::os::unix::ffi::OsStrExt;
3751    use std::path::Component;
3752    if name.is_empty() || name.contains(&0) || name[0] == b'/' {
3753        return false;
3754    }
3755    let path = Path::new(std::ffi::OsStr::from_bytes(name));
3756    if path.is_absolute() {
3757        return false;
3758    }
3759    path.components()
3760        .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
3761}
3762
3763/// `tar -tv` with `--quoting-style=shell-always`. Names may contain
3764/// newlines, so records are split on the shell quotes, not on lines.
3765fn parse_tar_listing(bytes: &[u8]) -> Result<Vec<ArchiveMember>> {
3766    let mut members = Vec::new();
3767    let mut i = 0;
3768    while i < bytes.len() {
3769        if bytes[i] == b'\n' {
3770            i += 1;
3771            continue;
3772        }
3773        let kind = bytes[i];
3774        let quote = bytes[i..]
3775            .iter()
3776            .position(|b| *b == b'\'')
3777            .context("unquoted tar member")?;
3778        let start = i + quote;
3779        let (name, used) = parse_shell_single(&bytes[start..])?;
3780        let mut j = start + used;
3781        let rest = &bytes[j..];
3782        if rest.starts_with(b" -> ") || rest.starts_with(b" link to ") {
3783            let target = rest
3784                .iter()
3785                .position(|b| *b == b'\'')
3786                .context("unquoted link target")?;
3787            let (_, used_target) = parse_shell_single(&rest[target..])?;
3788            j += target + used_target;
3789        }
3790        if j < bytes.len() && bytes[j] == b'\n' {
3791            j += 1;
3792        } else if j < bytes.len() {
3793            bail!("tar member list has trailing bytes");
3794        }
3795        members.push(ArchiveMember { kind, name });
3796        i = j;
3797    }
3798    Ok(members)
3799}
3800
3801/// One shell-always single-quoted word. `'\''` is a literal quote.
3802fn parse_shell_single(bytes: &[u8]) -> Result<(Vec<u8>, usize)> {
3803    if bytes.first() != Some(&b'\'') {
3804        bail!("tar member is not shell-quoted");
3805    }
3806    let mut out = Vec::new();
3807    let mut i = 1;
3808    while i < bytes.len() {
3809        if bytes[i] == b'\'' {
3810            i += 1;
3811            if bytes.get(i) == Some(&b'\\') && bytes.get(i + 1) == Some(&b'\'') {
3812                out.push(b'\'');
3813                i += 2;
3814                if bytes.get(i) != Some(&b'\'') {
3815                    bail!("broken shell quote in tar member");
3816                }
3817                i += 1;
3818                continue;
3819            }
3820            return Ok((out, i));
3821        }
3822        out.push(bytes[i]);
3823        i += 1;
3824    }
3825    bail!("unterminated shell quote in tar member")
3826}
3827
3828/// What the unpack left on disk. `symlink_metadata` does not follow a link,
3829/// so a link tar created is still a link here.
3830fn refuse_unsafe_tree(root: &Path) -> Result<()> {
3831    use std::os::unix::fs::MetadataExt;
3832    let mut pending = vec![root.to_path_buf()];
3833    while let Some(dir) = pending.pop() {
3834        for ent in fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
3835            let path = ent?.path();
3836            let meta =
3837                fs::symlink_metadata(&path).with_context(|| format!("stat {}", path.display()))?;
3838            let rel = path.strip_prefix(root).unwrap_or(path.as_path());
3839            let ft = meta.file_type();
3840            if ft.is_symlink() {
3841                bail!(
3842                    "encoder archive member {} is a symlink; nothing was installed",
3843                    rel.display()
3844                );
3845            }
3846            if ft.is_dir() {
3847                pending.push(path);
3848                continue;
3849            }
3850            if !ft.is_file() {
3851                bail!(
3852                    "encoder archive member {} is not a regular file; nothing was installed",
3853                    rel.display()
3854                );
3855            }
3856            if meta.nlink() != 1 {
3857                bail!(
3858                    "encoder archive member {} is a hard link; nothing was installed",
3859                    rel.display()
3860                );
3861            }
3862        }
3863    }
3864    Ok(())
3865}
3866
3867fn regular_file(path: &Path) -> bool {
3868    fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file())
3869}
3870
3871#[cfg(all(test, unix))]
3872mod archive_member_tests {
3873    use super::{archive_member_path_ok, parse_tar_listing, refuse_unsafe_archive};
3874
3875    #[test]
3876    fn shell_quotes_and_escaping_paths_parse() {
3877        let mut raw = b"-rw-r--r-- 0/0 3 1970-01-01 00:00:00 'bin/odd '".to_vec();
3878        raw.extend_from_slice(b"\\''name'\\''\nwith space'\n");
3879        raw.extend_from_slice(
3880            b"lrwxrwxrwx ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/packset-embed' -> '/etc/passwd'\n",
3881        );
3882        raw.extend_from_slice(
3883            b"hrw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/real' link to 'bin/packset-embed'\n",
3884        );
3885        raw.extend_from_slice(b"prw-r--r-- ubuntu/ubuntu 0 2026-10-22 01:11:10 'bin/fifo'\n");
3886        raw.extend_from_slice(b"-rw-r--r-- 0/0 17 1970-01-01 00:00:00 '/bin/packset-embed'\n");
3887        raw.extend_from_slice(b"-rw-r--r-- 0/0 7 1970-01-01 00:00:00 '../escaped'\n");
3888        let members = parse_tar_listing(&raw).unwrap();
3889        assert_eq!(members.len(), 6, "{members:?}");
3890        assert_eq!(members[0].kind, b'-');
3891        assert_eq!(members[0].name, b"bin/odd 'name'\nwith space");
3892        assert_eq!(members[1].kind, b'l');
3893        assert_eq!(members[2].kind, b'h');
3894        assert_eq!(members[3].kind, b'p');
3895        assert_eq!(members[4].name, b"/bin/packset-embed");
3896        assert_eq!(members[5].name, b"../escaped");
3897        assert!(archive_member_path_ok(&members[0].name));
3898        assert!(!archive_member_path_ok(&members[4].name));
3899        assert!(!archive_member_path_ok(&members[5].name));
3900        assert!(!archive_member_path_ok(b"bin/../../escaped2"));
3901        assert!(archive_member_path_ok(
3902            b"lib/onnxruntime/libonnxruntime.so.1"
3903        ));
3904        assert!(archive_member_path_ok(b"lib/"));
3905    }
3906
3907    #[test]
3908    fn tar_listing_refuses_a_symlink_member() {
3909        let root = tempfile::tempdir().unwrap();
3910        let dir = root.path().join("tree");
3911        std::fs::create_dir_all(dir.join("bin")).unwrap();
3912        std::os::unix::fs::symlink("/etc/passwd", dir.join("bin/packset-embed")).unwrap();
3913        let archive = root.path().join("bad.tar.gz");
3914        let out = std::process::Command::new("tar")
3915            .arg("-C")
3916            .arg(&dir)
3917            .arg("-czf")
3918            .arg(&archive)
3919            .arg("bin")
3920            .output()
3921            .unwrap();
3922        assert!(
3923            out.status.success(),
3924            "{}",
3925            String::from_utf8_lossy(&out.stderr)
3926        );
3927        let err = refuse_unsafe_archive(&archive).unwrap_err().to_string();
3928        assert!(err.contains("symlink"), "{err}");
3929        assert!(err.contains("nothing was installed"), "{err}");
3930    }
3931}
3932
3933#[cfg(test)]
3934mod tests {
3935    use super::*;
3936    use std::os::unix::fs::PermissionsExt;
3937
3938    const SECRET: &str = "AGE-SECRET-KEY-1PLANTED\n";
3939
3940    fn temp_of(path: &Path) -> PathBuf {
3941        path.with_file_name(format!(".cloud.age.tmp-{}", std::process::id()))
3942    }
3943
3944    #[test]
3945    fn a_link_planted_at_the_temp_name_is_not_followed() {
3946        let dir = tempfile::tempdir().unwrap();
3947        let path = dir.path().join("cloud.age");
3948        let outside = dir.path().join("outside");
3949        fs::write(&outside, "keep\n").unwrap();
3950        let tmp = temp_of(&path);
3951        std::os::unix::fs::symlink(&outside, &tmp).unwrap();
3952        assert!(write_private(&path, SECRET).is_err());
3953        assert_eq!(fs::read_to_string(&outside).unwrap(), "keep\n");
3954        assert!(fs::symlink_metadata(&tmp).unwrap().file_type().is_symlink());
3955        assert!(!path.exists());
3956    }
3957
3958    #[test]
3959    fn a_readable_file_planted_at_the_temp_name_never_holds_the_identity() {
3960        let dir = tempfile::tempdir().unwrap();
3961        let path = dir.path().join("cloud.age");
3962        let tmp = temp_of(&path);
3963        fs::write(&tmp, "planted\n").unwrap();
3964        fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)).unwrap();
3965        assert!(write_private(&path, SECRET).is_err());
3966        assert_eq!(fs::read_to_string(&tmp).unwrap(), "planted\n");
3967        assert_eq!(
3968            fs::metadata(&tmp).unwrap().permissions().mode() & 0o777,
3969            0o644
3970        );
3971        assert!(!path.exists());
3972
3973        fs::remove_file(&tmp).unwrap();
3974        write_private(&path, SECRET).unwrap();
3975        assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
3976        assert_eq!(
3977            fs::symlink_metadata(&path).unwrap().permissions().mode() & 0o777,
3978            0o600
3979        );
3980        assert!(!tmp.exists());
3981    }
3982
3983    /// A Dock app looks for a bare `ljos-mcp` on its own PATH, not on this
3984    /// process's PATH or a login shell's.
3985    #[test]
3986    fn a_launchd_app_looks_for_a_bare_server_on_its_own_path() {
3987        let scratch = tempfile::tempdir().unwrap();
3988        let bin = scratch.path().join("bin");
3989        let empty = scratch.path().join("empty");
3990        fs::create_dir_all(&bin).unwrap();
3991        fs::create_dir_all(&empty).unwrap();
3992        let server = bin.join("ljos-mcp");
3993        fs::write(&server, "#!/bin/sh\n").unwrap();
3994        fs::set_permissions(&server, fs::Permissions::from_mode(0o755)).unwrap();
3995        let launch = LaunchSpec {
3996            command: "ljos-mcp".into(),
3997            args: Vec::new(),
3998            env_path: None,
3999        };
4000        let file = |launch_path: &Path| McpFile {
4001            path: scratch.path().join("claude_desktop_config.json"),
4002            probe: McpProbe::Json("/mcpServers/ljos".into()),
4003            advice: "ljos onboard --harness claude-desktop".into(),
4004            expand: Expand::Shell,
4005            launch_path: Some(launch_path.display().to_string()),
4006            strict: false,
4007        };
4008        match launch_check(&file(&empty), &launch) {
4009            LaunchCheck::Missing(why) => {
4010                assert!(why.contains(&empty.display().to_string()), "{why}");
4011            }
4012            _ => panic!("ljos-mcp is not on the app's PATH"),
4013        }
4014        assert!(!matches!(
4015            launch_check(&file(&bin), &launch),
4016            LaunchCheck::Missing(_)
4017        ));
4018    }
4019
4020    #[test]
4021    fn mode_fix_refuses_a_symlink_planted_at_the_target_path() {
4022        let dir = tempfile::tempdir().unwrap();
4023        let path = dir.path().join("cloud.age");
4024        let outside = dir.path().join("outside");
4025        fs::write(&path, SECRET).unwrap();
4026        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4027        fs::write(&outside, "keep\n").unwrap();
4028        fs::set_permissions(&outside, fs::Permissions::from_mode(0o644)).unwrap();
4029        fs::remove_file(&path).unwrap();
4030        std::os::unix::fs::symlink(&outside, &path).unwrap();
4031
4032        assert!(set_mode_0600(&path).is_err());
4033        assert!(fs::symlink_metadata(&path)
4034            .unwrap()
4035            .file_type()
4036            .is_symlink());
4037        assert_eq!(
4038            fs::metadata(&outside).unwrap().permissions().mode() & 0o777,
4039            0o644,
4040            "a swapped symlink changed another file's mode"
4041        );
4042    }
4043
4044    /// O_NOFOLLOW on the last component still follows a symlink parent, so
4045    /// chmod would change the file in the directory the link points at.
4046    #[test]
4047    fn mode_fix_refuses_a_symlink_parent() {
4048        let dir = tempfile::tempdir().unwrap();
4049        let real = dir.path().join("real");
4050        fs::create_dir(&real).unwrap();
4051        let path = real.join("cloud.age");
4052        fs::write(&path, SECRET).unwrap();
4053        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
4054        let via_dir = dir.path().join("via");
4055        std::os::unix::fs::symlink(&real, &via_dir).unwrap();
4056        let via = via_dir.join("cloud.age");
4057
4058        let err = set_mode_0600(&via).unwrap_err();
4059        assert!(err.to_string().contains("symlink"), "{err}");
4060        assert_eq!(
4061            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4062            0o644,
4063            "a parent symlink changed the target's mode"
4064        );
4065
4066        let err = match install_identity(&via, SECRET) {
4067            Err(err) => err,
4068            Ok(_) => panic!("parent symlink was accepted"),
4069        };
4070        assert!(err.to_string().contains("symlink"), "{err}");
4071        assert_eq!(fs::read_to_string(&path).unwrap(), SECRET);
4072        assert_eq!(
4073            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4074            0o644
4075        );
4076
4077        let err = match install_identity(&via, "AGE-SECRET-KEY-1OTHER\n") {
4078            Err(err) => err,
4079            Ok(_) => panic!("parent symlink was written through"),
4080        };
4081        assert!(err.to_string().contains("symlink"), "{err}");
4082        assert_eq!(
4083            fs::read_to_string(&path).unwrap(),
4084            SECRET,
4085            "the identity was written through the parent symlink"
4086        );
4087        assert_eq!(
4088            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4089            0o644
4090        );
4091
4092        set_mode_0600(&path).unwrap();
4093        assert_eq!(
4094            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
4095            0o600,
4096            "a direct path is still mode 0600"
4097        );
4098    }
4099
4100    #[test]
4101    fn a_renamed_server_key_counts_when_its_command_is_the_seat() {
4102        let doc = serde_json::json!({
4103            "mcpServers": {
4104                "my__seat": {"command": "/usr/local/bin/ljos-mcp", "args": []},
4105                "github": {"command": "github-mcp"},
4106                "off": {"command": "ljos-mcp", "disabled": true},
4107                "via": {"command": "ljos", "args": ["mcp"]}
4108            }
4109        });
4110        let keys = json_seat_keys(&doc);
4111        assert!(keys.contains("my__seat"), "{keys:?}");
4112        assert!(keys.contains("via"), "{keys:?}");
4113        assert!(!keys.contains("github"), "{keys:?}");
4114        assert!(!keys.contains("off"), "{keys:?}");
4115    }
4116
4117    #[test]
4118    fn a_toml_server_table_names_the_seat_key() {
4119        let doc: toml::Value =
4120            toml::from_str("[mcp_servers.ljos]\ncommand = \"ljos-mcp\"\nargs = []\n").unwrap();
4121        let keys = toml_seat_keys(&doc);
4122        assert!(keys.contains("ljos"), "{keys:?}");
4123        let other: toml::Value =
4124            toml::from_str("[mcp_servers.github]\ncommand = \"github-mcp\"\n").unwrap();
4125        assert!(!toml_seat_keys(&other).contains("github"));
4126    }
4127
4128    #[test]
4129    fn a_yaml_marker_names_the_seat_when_the_command_matches() {
4130        let text = "extensions:\n  ljos:\n    command: ljos-mcp\n    args: []\n";
4131        assert_eq!(
4132            text_marker_key(text, "  ljos:\n    command:"),
4133            Some("ljos".to_string())
4134        );
4135        assert_eq!(text_marker_key(text, "  other:\n    command:"), None);
4136    }
4137
4138    #[test]
4139    fn a_strict_config_that_is_not_a_file_is_ambiguous() {
4140        let dir = tempfile::tempdir().unwrap();
4141        let path = dir.path().join("mcp.json");
4142        fs::create_dir(&path).unwrap();
4143        let file = McpFile {
4144            path: path.clone(),
4145            probe: McpProbe::Json("/mcpServers/ljos".into()),
4146            advice: String::new(),
4147            expand: Expand::Shell,
4148            launch_path: None,
4149            strict: true,
4150        };
4151        assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4152        let missing = McpFile {
4153            path: dir.path().join("absent.json"),
4154            probe: McpProbe::Json("/mcpServers/ljos".into()),
4155            advice: String::new(),
4156            expand: Expand::Shell,
4157            launch_path: None,
4158            strict: true,
4159        };
4160        assert!(matches!(read_seat_keys(&missing), KeyRead::Absent));
4161        fs::remove_dir(&path).unwrap();
4162        fs::write(&path, "{not json").unwrap();
4163        assert!(matches!(read_seat_keys(&file), KeyRead::Ambiguous));
4164        let loose = McpFile {
4165            strict: false,
4166            ..file
4167        };
4168        assert!(matches!(read_seat_keys(&loose), KeyRead::Skip));
4169    }
4170
4171    #[test]
4172    fn a_payload_command_names_the_seat_server() {
4173        assert!(payload_command_is_seat("ljos-mcp"));
4174        assert!(payload_command_is_seat("/opt/ljos/bin/ljos-mcp"));
4175        assert!(payload_command_is_seat("ljos-plugin ljos-mcp"));
4176        assert!(payload_command_is_seat("ljos mcp"));
4177        assert!(!payload_command_is_seat("github-mcp-server"));
4178        assert!(!payload_command_is_seat("ljos-plugin"));
4179        assert!(!payload_command_is_seat(""));
4180    }
4181
4182    #[test]
4183    fn a_large_claude_document_yields_only_mcp_servers() {
4184        let history = "x".repeat(1000);
4185        let mut body = String::from(
4186            r#"{"mcpServers":{"ljos.seat":{"command":"ljos-mcp","args":[]}},"projects":{"/w":{"history":["#,
4187        );
4188        body.push('"');
4189        body.push_str(&history);
4190        body.push_str(r#""]}}}"#);
4191        let found = claude_mcp_servers_value(&mut body.as_bytes());
4192        let ClaudeRead::Servers(text) = found else {
4193            panic!("mcpServers was not read");
4194        };
4195        assert!(text.contains("ljos.seat"), "{text}");
4196        assert!(!text.contains(&history[..32]), "history was kept: {text}");
4197        let doc: serde_json::Value = serde_json::from_str(&text).unwrap();
4198        let keys = json_seat_keys(&doc);
4199        assert!(keys.contains("ljos.seat"), "{keys:?}");
4200        assert!(keys.contains("ljos_seat"), "{keys:?}");
4201    }
4202
4203    #[test]
4204    fn encoder_ships_for_macos_and_linux_on_x86_64_and_aarch64() {
4205        for (arch, os, want) in [
4206            ("x86_64", "linux", "x86_64-unknown-linux-gnu"),
4207            ("aarch64", "linux", "aarch64-unknown-linux-gnu"),
4208            ("x86_64", "macos", "x86_64-apple-darwin"),
4209            ("aarch64", "macos", "aarch64-apple-darwin"),
4210        ] {
4211            assert_eq!(encoder_target_for(arch, os), Some(want));
4212        }
4213        assert_eq!(encoder_target_for("x86_64", "windows"), None);
4214        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
4215        let yml = fs::read_to_string(root.join(".github/workflows/release.yml")).unwrap();
4216        let triples: Vec<&str> = yml
4217            .lines()
4218            .filter_map(|l| l.trim().strip_prefix("triple: "))
4219            .collect();
4220        assert_eq!(
4221            triples,
4222            [
4223                "x86_64-unknown-linux-gnu",
4224                "aarch64-unknown-linux-gnu",
4225                "aarch64-apple-darwin",
4226                "x86_64-apple-darwin",
4227            ]
4228        );
4229        assert!(
4230            yml.contains("protoc-21.12-osx-universal_binary.zip"),
4231            "Darwin encoder build must pin protoc 3.21.12"
4232        );
4233        assert_eq!(
4234            yml.matches("ONNX_CUSTOM_PROTOC_EXECUTABLE=$PROTOC").count(),
4235            2,
4236            "both macOS and Linux encoder builds pass ONNX_CUSTOM_PROTOC_EXECUTABLE"
4237        );
4238        assert_eq!(
4239            yml.matches("ONNX_CUSTOM_PROTOC_EXECUTABLE:[A-Z]+=$PROTOC")
4240                .count(),
4241            2,
4242            "both macOS and Linux encoder builds verify ONNX_CUSTOM_PROTOC_EXECUTABLE in CMakeCache.txt"
4243        );
4244    }
4245}