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