#!/bin/sh # # ------------------------------------------------------------------ # IF YOU ARE READING THIS IN YOUR TERMINAL, KOSMOS DID NOT INSTALL. # # Nothing went wrong and nothing was broken. This is the installer's # own text, printed to your screen instead of being run. # # You need the whole line, including the part at the end: # # curl -fsSL https://installkosmos.com/setup | sh # # Copy that, paste it into Terminal, and press Return. # ------------------------------------------------------------------ # # ⚠️ THE FIRST TWELVE LINES OF THIS FILE ARE WRITTEN FOR SOMEBODY WHO IS NOT # US, AND THEY EARNED THEIR PLACE. On 2026-08-22 the first person outside this # team to try Kosmos ran `curl -fsSL .../setup` with no `| sh`. curl did as it # was asked, printed 2,169 lines of this file to her screen, and exited 0. She # reported to Josh that the product was broken, and she was being reasonable: # the failure is silent, succeeds, and looks catastrophic. # # Everything below this banner is written for whoever maintains the installer. # The top of the screen is the one moment we are guaranteed the attention of # somebody who has just watched two thousand lines scroll past, and it used to # be spent on /Applications icon staging -- true, careful, and useless to her. # (Splinter's catch. The page it came from was fixed the same night so the # command can no longer be half-selected; this is the belt for every other way # a person can arrive holding half of it.) # # Kosmos installer. One line, no sudo, no password. Everything lives in your # home folder except: the app icon, which goes to /Applications when this # user can write there without a password (macOS admin accounts can), and # into the Applications folder inside your home folder otherwise; the # icon's one-line registration with macOS (so Spotlight knows it exists); # and, on --uninstall, the launchd bookkeeping that removes agents' # background jobs. In # /Applications it only ever replaces a Kosmos icon it can prove it created # itself (by the icon's own contents); its write check creates and removes # one empty hidden folder there; and the icon is assembled in a hidden # .Kosmos.app.stage. folder beside its spot and renamed into place, # with the replaced icon renamed aside as .Kosmos.app.old. until the # swap completes (an interrupted run can leave either hidden folder # behind; --uninstall sweeps both when it can prove they are this # install's own, and names anything it leaves). macOS may show its own one-time # "Terminal wants to manage apps" dialog for the icon step. It never # touches any other app. A fresh # install that confirms its own board is running finishes by launching the # Kosmos app (#2073: app-only, no browser); updates never do, and # KOSMOS_NO_OPEN=1 turns it off. # # ⚠️ THE SHEBANG SAYS sh BECAUSE THE PAGE SAYS sh. This file's contract is # the interpreter the marketing line actually invokes: macOS /bin/sh, which # is bash 3.2 in POSIX mode (the Darwin gate below runs before anything # non-POSIX, so a Linux dash never gets past the first sentence). `local` # and `set -o pipefail` are safe under macOS sh specifically, and that is # the only sh this file supports. # # curl -fsSL https://installkosmos.com/setup | sh # # ⚠️ WHO THIS IS FOR, because it governs every decision below. The person running # this has been handed a line to paste by someone they trust, in a room, and has # possibly never opened Terminal before. They are not debugging. If something # goes wrong they will not read a stack trace, they will conclude the product is # broken and stop. So: # # - EVERY step prints what it is doing BEFORE it does it. A silent install is # the documented disqualifying failure (launch decision, 2026-08-11: a # silent install disqualifies the product): a blank terminal # for several minutes reads as broken, and the person quits before it # finishes. Measured on a competitor the same week this was written: ten # minutes of no output at all while it downloaded a database. # - Every failure prints what to do next, in a sentence, not an error code. # - Nothing needs sudo. Nothing is written outside $HOME except: the app # icon (and the write probe that decides where it can go), which uses # /Applications only when this user can already write there without a # password; the icon's one-line registration with macOS # (LaunchServices, so Spotlight knows it exists); and, on --uninstall, # the launchd enable/bootout that removes agents' background jobs. The # header sentence above lists the same three exceptions; when one # list changes the other must, because this file is served verbatim at # https://installkosmos.com/setup and these sentences are what a cautious # person reads before piping it into sh. # - Running it twice is safe and says so. # # ⚠️ AND IT MUST BE REVERSIBLE. `--uninstall` genuinely returns the machine to # before. That is not politeness: the first run on a never-touched Mac is the # most valuable test this project will ever get, and it is worth exactly once # unless we can put the machine back. One stated bound: anything the # uninstall cannot PROVE this installer created is left alone and named, # never deleted; on the rare machine where that leaves something behind, # the sentence says what it is. # ⚠️ THE macOS CHECK RUNS BEFORE ANY set OPTION. `set -o pipefail` is not # POSIX; on a Linux dash the old order died with a raw shell error before # reaching the friendly "Kosmos runs on macOS" sentence below. case "$(uname -s)" in Darwin) ;; *) printf '\n Kosmos runs on macOS. This looks like %s.\n\n' "$(uname -s)" >&2; exit 1 ;; esac set -euo pipefail KOSMOS_HOME="${KOSMOS_HOME:-$HOME/.local/share/kosmos}" # Slash-normalized, because install/kosmos self-derives ITS home from its # own location and the two strings must compare equal: a trailing slash # on $HOME (measured) made every ownership and board proof in this file # use //-flavored paths the launcher never bakes. KOSMOS_HOME="$(printf '%s' "$KOSMOS_HOME" | /usr/bin/tr -s '/')" KOSMOS_HOME="${KOSMOS_HOME%/}" # ⚠️ SHELL-SIGNIFICANT CHARACTERS IN KOSMOS_HOME ARE REFUSED OUTRIGHT. # Two separate mechanisms make them catastrophic rather than awkward. A # NEWLINE: the ownership checks below prove a bundle is ours with # `grep -F` on a token built from this value, and grep -F treats a # newline in the pattern as a pattern SEPARATOR -- the token degrades # into two alternatives, one of which (`}"`) matches every launcher ever # written, silently turning every ownership gate on every rm -rf # fail-OPEN. A QUOTE, DOLLAR, BACKTICK or BACKSLASH: the value is baked # into the generated .app launcher through an unquoted heredoc, so those # write a launcher that is syntactically broken or that runs command # substitution at CLICK time. Same posture as the KOSMOS_HOME=$HOME # guard on the uninstall path: the catastrophic misuses of an override # are refused in a sentence, not survived. # The closing brace is refused for a THIRD mechanism: the launcher bakes # the value inside ${KOSMOS_HOME:-}, so a } in the value closes the # expansion early and the launcher runs fine while resolving the WRONG # home -- an icon that alerts "could not start" forever, reproduced in # review with /tmp/ku}rt resolving to /tmp/kurt}. # (--uninstall is refused too, deliberately: its own deletion gates # depend on this same value, and running them against a poisoned one is # worse than asking the user to fix it first.) case "$KOSMOS_HOME" in /*) ;; *) printf '\n KOSMOS_HOME must be an absolute path (a relative one would resolve against whatever folder the icon is opened from). Unset it and run again.\n\n' >&2 exit 2 ;; esac # Dot components are refused too: the ownership and board proofs compare # this string EXACTLY against paths the launcher and the ps table carry, # and /tmp/./k versus /tmp/k would fail every proof while both name the # same folder. case "$KOSMOS_HOME" in */.|*/..|*/./*|*/../*) printf '\n KOSMOS_HOME must not contain . or .. path components. Unset it and run again.\n\n' >&2 exit 2 ;; esac case "$KOSMOS_HOME" in *" "*|*'"'*|*'$'*|*'`'*|*'\'*|*'}'*) printf '\n KOSMOS_HOME contains a character (newline, quote, dollar, backtick, backslash or }) that would defeat the safety checks below. Unset it (or fix the home folder it defaults from) and run again.\n\n' >&2 exit 2 ;; esac # 🔑 A SANDBOXED KOSMOS_HOME SANDBOXES EVERY MACHINE-GLOBAL SURFACE, DERIVED # HERE, ONCE, BEFORE ANYTHING READS THEM (#928, #934). The #883/#917 # derivation further down covers the three data roots, and only at the # install path's start step; uninstall dispatches before it (#924's lesson) # and these four never had one at all. So a walk that set only KOSMOS_HOME # (Pete's convention) still repointed the operator's REAL ~/.local/bin/kosmos # at the sandbox (#928, live incident: dangling after the tmp cleaner ate # the target) and wrote its PATH line into the real ~/.zprofile, on every # install AND on every auto-update tick, which re-runs this file. # KOSMOS_HOME_APP_DIR is NOT derived: ~/Applications already carries the # #226 ownership proof (a bundle is claimed only when its launcher names # THIS KOSMOS_HOME), and sixteen harness scenarios exercise a fake # HOME/Applications on purpose; deriving it moved every walk's icon and # went red in the gate, measured 2026-08-26. # Caller's explicit value always wins, matching every other ${VAR:-default} # here. KOSMOS_APP_DIR is deliberately NOT derived: an override there means # "no probing", and a sandboxed install must still exercise the # /Applications ownership proof the harness tests with KOSMOS_APP_DIR= and # a sandboxed KOSMOS_SYS_APP_DIR. AGENT_WORKFORCE_CLAUDE_BIN is NOT derived # either, measured rather than reasoned: the carry (#548) runs Anthropic's # installer, which lands at its own ~/.local/bin/claude, so a derived path # made the installer unable to find what it had just installed (the gate's # first leg went red). Claude Code is the person's real tool at its real # path, not a sandbox surface; a harness pins it to a shared copy (#736). # The default is computed the same way the # two later copies compute it (slash-normalised like KOSMOS_HOME itself). _kosmos_home_default="$(printf '%s' "$HOME/.local/share/kosmos" | /usr/bin/tr -s '/')" _kosmos_home_default="${_kosmos_home_default%/}" if [ "$KOSMOS_HOME" != "$_kosmos_home_default" ]; then [ -n "${KOSMOS_BIN_DIR:-}" ] || export KOSMOS_BIN_DIR="$KOSMOS_HOME/localbin" [ -n "${KOSMOS_PROFILE_FILE:-}" ] || export KOSMOS_PROFILE_FILE="$KOSMOS_HOME/zprofile" fi BIN_DIR="${KOSMOS_BIN_DIR:-$HOME/.local/bin}" # ONE definition for the profile-wiring literals, used by the install # wiring AND the uninstall sweep: two derivations of these strings is how # the sweep silently stops matching what install wrote. PATH_MARKER="# kosmos: PATH for the kosmos command (removed by --uninstall)" PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\"" # ⚠️ Overridable for the same reason the sources are: the sandboxed test of # this installer must not write an app icon into the real Applications # folders of the machine it runs on. Everything this script writes goes # under a root the test can point somewhere disposable. When the override is # set it is used VERBATIM -- no probing, no fallback -- so a sandbox stays a # sandbox. # # ⚠️ WITHOUT the override, the icon goes to /Applications when this user can # write there without a password, and only otherwise to ~/Applications. # Measured on the first real clean-machine run (2026-08-13): the icon went # to ~/Applications, the tester opened Finder's Applications (which shows # /Applications), and concluded it "did not put it in my applications". For # this installer's audience, an app that is not where people look does not # exist. macOS gives admin users group write on /Applications, so the common # case needs no password; the probe is an actual mkdir, not `-w`, because # ACLs can make `-w` lie in both directions. # ⚠️ THE ownership predicate, defined ONCE. Every rm -rf and every claim # in this file is gated by this exact function; it was previously # hand-assembled at a dozen sites in four different guard combinations, # and every gap a review found was a site missing one guard the others # had. It returns 0 only for a REAL directory bundle -- no symlink at # the root, at Contents, at MacOS, or at the launcher leaf, because a # link at any level would make the content check read a file the bundle # does not own. # # ⚠️ TWO LAUNCHER SHAPES SHIP NOW (#677). The OLD bash-heredoc launcher # carries this install's anchored token IN the executable itself # (`:-}"`, closing brace and quote included, so a prefix-related # home cannot cross-match). The NEW compiled Swift binary is IDENTICAL # across every install -- it is never re-baked per install -- so its # anchor cannot live in the executable at all; it lives in the # per-install Contents/Resources/kosmos-install.json instead, written # with the same anchoring discipline (`"kosmosHome":""`, closing # quote included). The old check runs first (cheap, and every install on # disk before #677 is this shape); the new one is a fallback tried only # when the old one does not match, never the reverse -- an old bundle is # never asked to satisfy a proof it was never written to carry. # /usr/bin/grep by absolute path throughout: this answer decides where # rm -rf points, so it must not be answerable by whatever a user's PATH # puts in front of grep (the same argument the write probe's /bin/mkdir # records). bundle_is_ours() { [ -d "$1" ] || return 1 [ ! -L "$1" ] || return 1 [ ! -L "$1/Contents" ] || return 1 [ ! -L "$1/Contents/MacOS" ] || return 1 [ ! -L "$1/Contents/MacOS/Kosmos" ] || return 1 # A regular file, or no proof: a FIFO at the leaf would block the grep # forever (measured), and a hang with no sentence is the worst outcome # this file is written against. Same hardening class and cost as the # stage's bare mkdir. [ -f "$1/Contents/MacOS/Kosmos" ] || return 1 /usr/bin/grep -qF ":-$KOSMOS_HOME}\"" "$1/Contents/MacOS/Kosmos" 2>/dev/null && return 0 # The new-shape fallback: same symlink and regular-file discipline as # the executable above, applied to the config file instead, because a # link at this leaf would make the fallback read a file the bundle does # not own just as surely as a link at the executable would. [ ! -L "$1/Contents/Resources/kosmos-install.json" ] || return 1 [ -f "$1/Contents/Resources/kosmos-install.json" ] || return 1 /usr/bin/grep -qF "\"kosmosHome\":\"$KOSMOS_HOME\"" "$1/Contents/Resources/kosmos-install.json" 2>/dev/null } # SYS_APP_DIR is overridable ONLY so the harness can drive the probe AND its # fallback against disposable directories -- a fallback that can only run # where the primary works is untested by construction, and the probe's # failure leg is exactly the one a standard (non-admin) user will live on. # Test-only by contract, and SYMMETRIC by obligation: an install driven # with this override must be uninstalled with the same value, or the # sweep looks at the real /Applications and the sandboxed icon is # orphaned. SYS_APP_DIR="${KOSMOS_SYS_APP_DIR:-/Applications}" # 🔑 THE ONE NAME FOR THE PERSON'S APPLICATIONS FOLDER (#721). Every reach the # installer makes into ~/Applications goes through this: the icon write when # the system folder is not writable, the retry, the migration block, the # uninstall sweep. A walk that must keep HOME real (a signed-in Claude lives # under it) sets KOSMOS_HOME_APP_DIR to a disposable folder and the operator's # real ~/Applications is never touched; a sandboxed install once decorated it # with a launcher into the sandbox (Pete, 2026-08-24 20:44). Third instance of # one shape tonight, with KOSMOS_HOME_APP_DIR (migration) and # KOSMOS_TMUX_BIN_PICKED (the picker): a seam named independently of the thing # a real machine is recognised by. install.home-apps-seam.test.js keeps the # class closed. Test-only by contract, like SYS_APP_DIR: #226 made that block skip under EITHER app-dir # override so a run sandboxed only by KOSMOS_SYS_APP_DIR could never rm the REAL # ~/Applications copy; the cost was that the harness could never exercise the # migration at all (seven checks red since #442, 2026-08-23). A harness that # NAMES a disposable folder here gets the block back, and only ever on that # folder. Unset outside a harness: the block sees the real ~/Applications and # the #226 gate still applies exactly as before. HOME_APP_DIR="${KOSMOS_HOME_APP_DIR:-$HOME/Applications}" # ⚠️ APP_DIR IS RESOLVED LAZILY, by the install path only, right before the # icon is written. Resolving it here would run the write probe on EVERY # invocation -- --uninstall, the unrecognised-flag refusal, the platform # refusals -- and a run that refuses to do anything must not mutate # /Applications. Until resolve_app_dir runs, APP_DIR carries the override # or the safe per-user default, which is all the uninstall path needs. APP_DIR="${KOSMOS_APP_DIR:-$HOME_APP_DIR}" APP_OTHER_OWNER=no APP_SKIP_ICON=no APP_SKIP_REASON="" APP_SYS_STALE=no APP_SYS_FAILED=no APP_HOME_FOREIGN=no resolve_app_dir() { # The verbatim override is a sandbox: no probing, no fallback. [ -n "${KOSMOS_APP_DIR:-}" ] && { APP_DIR="$KOSMOS_APP_DIR"; return 0; } APP_DIR="$HOME_APP_DIR" # ⚠️ NEVER CLAIM A BUNDLE THIS INSTALL CANNOT PROVE IS ITS OWN. The # launcher bakes the installing user's KOSMOS_HOME as its default, so on # a Mac with two admin accounts, replacing the shared /Applications icon # would break the other account's working install, and every reinstall # would clobber the icon back and forth between them. # # ⚠️ POSITIVE PROOF, NOT ABSENCE OF DISPROOF. make_app begins with rm -rf # on its target, so claiming the path IS the destructive act. If ANYTHING # sits at the system path, it is claimed only on evidence: the launcher # line naming this KOSMOS_HOME, matched WITH its closing token # (`:-}"`) so two homes in a prefix relationship cannot # cross-match. Everything else -- a third-party app that happens to be # named Kosmos, a half-written bundle, an unreadable launcher, a # different executable name -- diverts to the per-user folder, and the # icon step says so. (The first version keyed on the launcher FILE # existing, which made the install fail destructive exactly where the # uninstall below fails safe: a stranger's app was rm -rf'd while the # transcript printed success.) # -e OR -L, like every occupancy gate in this file: a symlink entry whose # target cannot be stat'd (dangling, or pointing into another user's # mode-700 home) fails -e alone, and the probe would then claim the slot # and rm -rf the link -- install failing destructive on exactly the # multi-account shape the uninstall below refuses. Measured in review. # A LINK ENTRY IS NEVER OURS: this installer never creates links, and # grep would follow one onto whatever it points at, claiming a user's # link to our own bundle and silently deleting it. Linkness decides # first; only a real directory earns the content check. if { [ -e "$SYS_APP_DIR/Kosmos.app" ] || [ -L "$SYS_APP_DIR/Kosmos.app" ]; } \ && ! bundle_is_ours "$SYS_APP_DIR/Kosmos.app"; then APP_OTHER_OWNER=yes # ⚠️ THE DIVERT ITSELF NEEDS THE ALIASING GUARD. "Send the icon to the # per-user folder instead" is only an escape if the per-user folder is # a DIFFERENT folder: with ~/Applications symlinked to the system one, # writing "to the home folder" resolves straight back onto the foreign # bundle this branch just refused to claim, and make_app's opening # rm -rf would destroy it under a sentence saying it was left alone. # Same physical folder, or existing-but-unresolvable: no icon at all, # said honestly, fail closed. A home Applications folder that does not # exist yet cannot alias anything, so the divert proceeds and creates # it. if [ -e "$HOME_APP_DIR" ] || [ -L "$HOME_APP_DIR" ]; then _home_apps_phys="$(cd "$HOME_APP_DIR" 2>/dev/null && pwd -P)" || _home_apps_phys="" _sys_apps_phys="$(cd "$SYS_APP_DIR" 2>/dev/null && pwd -P)" || _sys_apps_phys="" # The two skip reasons get distinct sentences: "same folder" was # OBSERVED only on the equal-paths leg; the unresolvable legs know # merely that the folder could not be checked, and the sentence must # not claim more than that. if [ -n "$_home_apps_phys" ] && [ -n "$_sys_apps_phys" ]; then [ "$_home_apps_phys" = "$_sys_apps_phys" ] && { APP_SKIP_ICON=yes; APP_SKIP_REASON=same; } else APP_SKIP_ICON=yes APP_SKIP_REASON=unknown fi fi return 0 fi # Old probe residue is cleaned before probing (the rmdir below is # best-effort, so a bizarre failure could have left one), which keeps # litter from ever accumulating; -f makes an unmatched glob harmless. # (Two accounts installing at the same instant could sweep each other's # live probe and fail one install with an unexplained transcript; # bounded, rare, and accepted rather than complicated away.) rm -rf "$SYS_APP_DIR"/.kosmos-write-probe.* 2>/dev/null || true # /bin/mkdir and /bin/rmdir by absolute path: the probe's answer decides # where an rm -rf will later point, so it must not be answerable by # whatever a user's PATH puts in front of mkdir. if /bin/mkdir "$SYS_APP_DIR/.kosmos-write-probe.$$" 2>/dev/null; then /bin/rmdir "$SYS_APP_DIR/.kosmos-write-probe.$$" 2>/dev/null \ || rm -rf "$SYS_APP_DIR/.kosmos-write-probe.$$" 2>/dev/null || true APP_DIR="$SYS_APP_DIR" elif bundle_is_ours "$SYS_APP_DIR/Kosmos.app"; then # The probe FAILED on a machine where an earlier run of this install # already put an icon in the system folder (admin rights since lost, # folder since locked). The fresh icon goes to the home folder, and # the now-unreachable system icon is NAMED, or the user ends up with # two Kosmos icons and a sentence describing one. APP_SYS_STALE=probe fi return 0 } # The port everything below names. Overridable for the sandboxed installer # test; the app icon and the closing sentences bake in whatever was installed. # 🔑 16180 RATHER THAN 4317, and the reason is neighbourhood rather than taste. # 4317 is the OpenTelemetry OTLP/gRPC default and 4318 its HTTP sibling, so the # people most likely to collide with Kosmos were the people already running # agents -- exactly this product's audience. Josh picked 16180 (the golden # ratio) and it checks out: nothing in the service registry, nothing clustered # near it, and it is memorable enough to type. # # ⚠️ AND IT IS DELIBERATELY NOT IN 49152-65535, which was the tempting answer # because no software ships a default there. MEASURED on macOS: # net.inet.ip.portrange.first: 49152 # net.inet.ip.portrange.last: 65535 # That range IS the ephemeral pool the kernel hands out for outgoing # connections, so a fixed listener in it would collide occasionally, randomly, # and only sometimes -- an intermittent failure nobody can reproduce, which is # worse than the deterministic one it replaced. The registered range is the # quiet, stable part and that is where this sits. # # 📌 AN EXISTING INSTALL KEEPS THE PORT IT HAS. `kosmos` reads its own state, and # KOSMOS_PORT still overrides everything here, so nothing moves under somebody # who is already running. # 🔑 PER ACCOUNT, NOT ONE VALUE FOR EVERY macOS USER ON THIS MACHINE (#910). # Every account defaulting to the identical port is the entire reason a # second macOS account's Kosmos loaded the first account's real agents: # 127.0.0.1 is machine-wide, so `healthy()` always found account A's board # first and account B's install never needed to bind a port of its own. # uid 501 (the Setup Assistant's first created user account on every # personal or family Mac -- macOS reserves anything below 500 for system # accounts) is pinned to the LITERAL unchanged value: every real install # today is hardcoded to exactly this, so the single most common Kosmos # install on this planet changes zero observable bytes. Every other uid # gets a deterministic, stable alternate -- `+1` on the modulo so it can # never itself land back on 16180 by coincidence (uid % 4000 alone can be # exactly 0). No probing, no persisted state: a pure function of `id -u`, # so `kosmos start`/`stop`/`status` and this installer always agree with # each other and with themselves, regardless of what any OTHER account's # board happens to be doing at the moment. Moves together with the exact # same formula in install/kosmos, install/pkg-scripts/postinstall, and # native-app/main.swift. _kosmos_uid="$(/usr/bin/id -u)" if [ "$_kosmos_uid" = 501 ]; then _kosmos_default_port=16180 else _kosmos_default_port=$((16180 + 1 + (_kosmos_uid % 3999))) fi PORT="${KOSMOS_PORT:-$_kosmos_default_port}" # The port is baked into the same unquoted launcher heredoc the # KOSMOS_HOME character guard protects, so it gets the same posture: # anything but digits is refused in a sentence (reproduced in review: a # crafted KOSMOS_PORT ran a command at click time). The '' arm is # unreachable belt (the :- default already replaced an empty value). # Length-bounded BEFORE any numeric compare: test(1) overflows on huge # digit strings and fails OPEN with raw shell errors (measured), so the # case refuses empties, non-digits, leading zeros (they would be baked # verbatim into the launcher URL), and anything over five digits, and # only then is the in-range compare safe. case "$PORT" in ''|*[!0-9]*|0*|??????*) printf '\n KOSMOS_PORT must be a number from 1 to 65535, with no leading zeros. Unset it and run again.\n\n' >&2 exit 2 ;; esac if [ "$PORT" -gt 65535 ]; then printf '\n KOSMOS_PORT must be a number from 1 to 65535. Unset it and run again.\n\n' >&2 exit 2 fi LOG_DIR="$KOSMOS_HOME/logs" LOG="$LOG_DIR/install.log" # ---- where the pieces come from -------------------------------------------- # ⚠️ BOTH SOURCES ARE OVERRIDABLE, and that is what makes the clean-machine test # possible. On a release these fetch from the published URL. For the first run on # a never-touched Mac we want to test the INSTALLER, not the CDN, so # KOSMOS_TMUX_SRC and KOSMOS_SRC can point at local files carried over on a # thumb drive. Same code path, one variable different. KOSMOS_RELEASE_BASE="${KOSMOS_RELEASE_BASE:-https://installkosmos.com/dist}" # ⚠️ EVERY DOWNLOAD IS CHECKSUM-VERIFIED before anything is extracted. The # build publishes a .sha256 next to each tarball; a mismatch, a truncated # download, or a missing checksum file all refuse in a sentence. # ⚠️ WHAT THIS IS AND IS NOT: the checksum travels from the SAME origin over # the SAME channel as the tarball, so it catches corruption, truncation and # a half-updated CDN -- it adds nothing against a compromised origin, which # already served this very script. Signing with a key that does not travel # beside the artifact is the upgrade, and is on the launch security list. # ⚠️ shasum, not sha256sum: macOS ships shasum, and this is the user path # where nothing beyond a clean Mac may be assumed. verify_download() { local file="$1" url="$2" shaurl="${3:-$2.sha256}" want got curl -fsL -m 30 "$shaurl" -o "$file.sha256" 2>/dev/null || { info "the download could not be verified (its verification file is missing)." info "This usually means the download site is mid-update. Wait a minute, then paste the install line again." return 1 } want="$(awk '{print $1; exit}' "$file.sha256")" got="$(shasum -a 256 "$file" | awk '{print $1}')" rm -f "$file.sha256" if [ -z "$want" ] || [ "$want" != "$got" ]; then info "the download did not arrive intact." info "Paste the install line again; if it keeps happening, the download site may be mid-update." return 1 fi return 0 } # A HEAD probe first, and a one-byte ranged GET before refusing: some static # origins reject HEAD (405) while serving GET fine, and "check your internet # connection" for a working connection is the wrong sentence. # ⚠️ A STATUS CODE CANNOT ANSWER "IS THE DOWNLOAD THERE". This used to accept # any response and therefore accepted every url ON AN HTTP ORIGIN WHOSE 404 PAGE # ANSWERS A RANGE REQUEST, including names that cannot exist. (Not literally # every url: a missing `file://` path already failed, rc 37, and there is an arm # for it.) The range arm asks a web server for the first byte of its own 404 # page and gets `206 text/html, 1 byte`, which is a success. Measured # 2026-08-31 against a deliberately impossible name, which PASSED. # # The cost was not a wrong answer, it was SILENCE. The guard could never fire, so the sentence written for exactly this case ("could not reach # the download ... it is safe to re-run") was dead code, and a person whose # download was missing met a bare curl failure with no guidance instead. Every # caller here fetches a TARBALL, so the answer is knowable: assert the content # type, and an error page can no longer impersonate a download. # # 🛑 A FIX HERE IS ONLY PROVEN BY A URL THAT CANNOT EXIST RETURNING FALSE. The # broken version passed on real files too, so "it still finds the tarball" is # not evidence of anything. See install.reachable-1662.test.js, which asserts # both arms against a local server. _reachable_is_download() { # $1 = curl's exit status, $2 = the content-type it reported. # # 🛑 REFUSE WHAT IS POSITIVELY TEXTUAL; DO NOT DEMAND A KNOWN BINARY. An # allowlist of binary types looks stricter and is wrong here, because the # cost is asymmetric: a false YES only means this pre-check did not help and # curl fails a few lines later with its own error, which is the behaviour # before this guard existed. A false NO stops the install outright behind # "Check your internet connection", which is worse than the bug it is for. # # ⚠️ THAT ASYMMETRY HOLDS AT TWO OF THE THREE CALL SITES, NOT ALL THREE, and # saying it unconditionally would be false. At the `TARGET_VERSION` probe # this predicate is an EXISTENCE TEST, not an abort guard: a false NO there # does not stop the install, it falls to the next branch. Downstream # `verify_download` still checks the sha and the version refusal still fires. # # 🛑 AND THE COST OF THAT FALL-THROUGH IS SMALL FOR EVERY NETWORK INSTALL, # which is the point most easily got wrong here. # `BUST=yes` is set for any http/https base and # `install_kosmos` runs after that, so the fallback is the `elif` arm, which # fetches the cache-BUSTED `kosmos-$ARCH.tar.gz?v=...`. A cache treats that as # a fresh resource, so there is no collision to inherit. The bare unversioned # name is reached only when BUST is empty, i.e. `file://` bases, which have no # cache to collide with in the first place. So a false NO here costs the # version-named artifact, not a stale one. # # ⚠️ AND AN ALLOWLIST BREAKS THE PROJECT'S OWN INSTALL GATE. `curl` on a # `file://` URL succeeds and reports an EMPTY content-type (measured: a real # gzip gives content_type=[] with exit 0), and tools/test-install.sh drives # the whole release path over `file://` on purpose. An allowlist refuses a # genuine tarball there and aborts the download path. # # ⚠️ Media types are case-insensitive (RFC 9110 section 8.3), so the compare # is lowercased. The case that matters is a CAPITALISED TEXTUAL type being # refused (`Text/HTML`), not a capitalised binary one being accepted: unknown # types are accepted anyway, so an `Application/GZIP` arm proves nothing. # # 🛑 `text/html` AND NOT `text/*`, DELIBERATELY. `text/plain` is nginx's # compiled-in `default_type`, so a mirror that has not mapped `.gz` serves a # genuine tarball as `text/plain`. Refusing it would block that install # behind "Check your internet connection" -- the exact false NO this design # is built to avoid, and `KOSMOS_RELEASE_BASE` is overridable, so a mirror is # a real case rather than a hypothetical one. A plain-text error page passing # is the harmless direction: curl fails a few lines later with its own error. # # The exit status is checked FIRST and separately, because an empty # content-type from a FAILED connection must not read the same as an empty # one from a local file that is genuinely there. # # 📌 THIS IS DELIBERATELY LOOSER THAN `serves_gzip()` in # tools/kosmos-artifact-check.sh, which requires the type to CONTAIN gzip. # They judge the same header for opposite stakes, so they should not match: # that one gates a RELEASE, where a false NO safely blocks a bad cut and a # false YES ships one; this one gates a PERSON'S INSTALL, where a false NO # is an installer that refuses to run. Tightening this to match it would # reintroduce the file:// break above. [ "$1" = 0 ] || return 1 # /usr/bin/tr, matching the six other ABSOLUTE `tr` call sites in this file (a # seventh, at the `_pids` line, is bare). The reason is # narrow and worth stating so nobody generalises it: the design is fail-open, # so a `tr` that did not resolve would empty the substitution, match no arm, # and silently accept EVERY type. That is the harmless direction by this # predicate's own asymmetry, but it would make the guard invisible rather # than noisy. It is NOT a general absolute-path policy: `curl` two lines # below is bare, as it is everywhere else in this file. case "$(printf '%s' "$2" | /usr/bin/tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in text/html*|application/xhtml*|application/json*|application/*+json*|application/xml*|application/*+xml*|text/xml*) return 1 ;; esac return 0 } # ⚠️ THE NAME IS WIDER THAN THE CONTRACT. This answers "is this a NON-TEXTUAL # DOWNLOAD", not "is this URL reachable": a perfectly reachable JSON or XML URL # is refused on purpose. Every caller here fetches a tarball, so that is the # question they are asking -- but do not reuse this for a general reachability # test. `latest.json` is fetched by bare curl further down for exactly that # reason, and routing it through here would refuse it. reachable() { # HEAD first; the range GET is the fallback for hosts that refuse HEAD. # A 404 page answers a range request with 206 and its own HTML body, so the # status alone cannot tell a download from an error page: the type must be # judged too, on both arms. # 🛑 `cmd && rc=0 || rc=$?`, NOT a bare assignment. This file runs under # `set -euo pipefail`, set near the top of this file, where a plain # `_r_ct=$(curl …)` is an UNPROTECTED SIMPLE COMMAND: a failing HEAD probe aborts the whole shell # before the range-GET fallback can run. The pre-#1662 form happened to be # safe because `curl … && return 0` was shielded by the `&&`. # Both shapes were measured under `set -euo pipefail`; the transcript is in # .claude/plans/reachable-1662.md rather than here. # Latent rather than live today, because -e is suspended at all three call # sites: the two guards in fetch_tmux and install_kosmos capture the status # with `|| _r_why=$?`, and the probe that picks the versioned tarball is the # right side of a `&&`. (This sentence used to describe the guards as # `if ! reachable`, the shape THIS branch replaced. The conclusion held and # the description had rotted, which is the failure the paragraph below is # about.) It is still a trap, # and it lands exactly on the fallback's reason for existing: a 405 on HEAD. # The call sites are named by their surrounding code above rather than by # position, because nothing checks a line number in a comment. local _r_ct _r_rc _r_out _r_code _r_answered # 🛑 ACCUMULATED ACROSS BOTH PROBES, NEVER OVERWRITTEN. The second probe used # to clobber the first probe's status, so a HEAD that got a definite answer # (a hard 404, or a 200 carrying HTML) followed by a range GET that failed to # COMPLETE (reset, DNS blip, an origin that drops the second connection) left # code 000 with a non-zero rc, and the caller told the user to check a # connection about a server that had demonstrably answered. Same wrong-sentence # class this card removes, pointed the other way. # # 📌 `if` rather than `{ …; } && _r_answered=1` is READABILITY ONLY, not # safety. An AND-OR list is EXEMPT from `set -e` whether or not the left side # fails, measured on /bin/sh with a control. This file uses that shape 14 # times, including the `= 63` remap in the range-GET arm below, so do not # "fix" them. (Plan file has the measurement.) Named by its surrounding code # rather than by a distance, per the rule stated on the `local` declaration # that opens this function: the old reference said "twenty lines" and had # become 82. I then wrote "twelve lines above" for the rule itself and that was # wrong too, by three. A distance in a comment is wrong the moment anyone edits # above it, which is the whole reason the rule exists. _r_answered=0 # %{http_code} FIRST because a content type contains spaces ("text/html; # charset=utf-8") and a status code never does, so the split is unambiguous. _r_out=$(curl -fsIL -m 15 -o /dev/null -w '%{http_code} %{content_type}' "$1" 2>/dev/null) && _r_rc=0 || _r_rc=$? _r_code=${_r_out%% *}; _r_ct=${_r_out#* } case "$_r_code" in ''|*[!0-9]*) _r_code=0 ;; esac case "$_r_out" in *' '*) ;; *) _r_ct='' ;; esac # 🛑 A METHOD REFUSAL IS NOT AN ANSWER ABOUT THE ARTIFACT. 405 and 501 mean # "I do not do HEAD", which says nothing about whether the file exists, so # they must not set _r_answered here. Without this, an origin that refuses # HEAD and whose range GET then fails to COMPLETE (reset, DNS blip) got # status 2 and was told to check the address, when the truth is a transient # connection failure that wants "it is safe to re-run". That is this card's # own wrong-sentence defect aimed at the shape the fallback exists for. # A 405 followed by a SUCCESSFUL range GET is unaffected: the range arm sets # the flag on its own rc. # 🛑 rc 37 GETS ITS OWN STATUS, IT IS NOT "THE SERVER ANSWERED". 37 is # FILE_COULDNT_READ_FILE: a `file://` path that is absent or unreadable. There # is no server, no release and no network, so folding it into status 2 printed # two false causes at a reader whose only true one is the path. Three separate # reviewers found that sentence before it was split out. [ "$_r_rc" = 37 ] && return 3 if [ "$_r_rc" = 0 ]; then _r_answered=1 elif [ "$_r_code" -ge 400 ] && [ "$_r_code" != 405 ] && [ "$_r_code" != 501 ]; then _r_answered=1 fi _reachable_is_download "$_r_rc" "$_r_ct" && return 0 # 📌 This arm also runs when HEAD SUCCEEDED with a textual type, where the # pre-#1662 code reached it only after a HEAD failure. Kept because refusing # on a single mis-typed HEAD would be a false NO, and there is an arm # asserting it buys that. # # ⚠️ THE COST, MEASURED ON BOTH ARMS. It is NOT the 30s hung-origin # case: measured, the pre-#1662 predicate also ran both probes to full # timeout there (HEAD times out, the `&&` falls through, the range GET runs), # old 30s NO and new 30s NO, identical. And NO for a host that cannot be # reached is the CORRECT answer, not a false one. # # The genuinely new cost is one extra request after a textual HEAD, normally # fast. The real residual is narrower and worth naming: against an origin # that IGNORES Range and answers 200 with the whole body, that request # streams the tarball, and on a slow enough link `-m 15` expires and the # predicate answers NO on a genuine download. That needs a mis-typed HEAD AND # a Range-ignoring origin AND a slow link together. It is untested here: a # faithful test would have to burn the full 15s timeout, which buys one # narrow arm at the price of a slow and timing-dependent suite. # --max-filesize bounds the residual named above: an origin that ignores # Range answers with the WHOLE body, and without a cap this probe would # stream a 48MB tarball into /dev/null until -m 15 expired. # # ⚠️ THAT BOUND IS CURL-VERSION DEPENDENT, SO DO NOT READ IT AS A GUARANTEE. # The macOS manpage says "the transfer does not start", i.e. the decision is # made from an ANNOUNCED content-length; and curl only began aborting an # already-running transfer in 8.4.0. tools/macos-floor declares this # installer's floor as 13.5, which ships curl 8.1.x. So against an origin that # omits content-length, on the floor OS, the residual is bounded by -m 15 and # not by this cap. Measured on curl 8.7.1 the cap DOES stop a length-less # transfer mid-flight, which is why the test arm for that shape asserts the # VERDICT and deliberately does not pin a byte count: the count is the part # that legitimately differs across versions. # # ⚠️ THE HTML-ON-63 REFUSAL ALSO RESTS ON A CURL VERSION, and this comment is # scrupulous about that everywhere else, so it should be here too. Refusing a # capped HTML body needs curl to still REPORT a content-type alongside exit # 63. Measured on 8.7.1 it does. On the 13.5 floor's curl 8.1.x the abort # happens before the transfer starts, and if content_type is empty there that # arm flips NO to YES. The shipped-code direction is the harmless one, a false # YES that curl catches a few lines later; the cost is a red suite on a # floor-OS runner rather than a broken install. # # 🛑 AND EXIT 63 MUST BE TREATED AS A SUCCESSFUL FETCH. Getting this wrong is # a REGRESSION, not a missed improvement. curl exits 63 when the # cap is hit, `_reachable_is_download` saw non-zero, and a GENUINE download # from a HEAD-refusing Range-ignoring origin was refused with "could not # reach the download". Measured against exactly that shape serving a real # 5MB body: capped NO, uncapped YES. That is the false-NO direction this # predicate's whole design says is the worst outcome. # # ⭐ A body that EXCEEDS the cap is affirmatively NOT a small error page, so # 63 is evidence FOR a download rather than against one. curl still reports # the content-type on 63 (measured, for both `application/gzip` and # `text/html`), so mapping it to 0 hands the decision to the type rule rather # than short-circuiting it: a 5MB gzip is accepted, a 5MB HTML page is still # refused. _r_out=$(curl -fsL -r 0-0 -m 15 --max-filesize 1048576 -o /dev/null -w '%{http_code} %{content_type}' "$1" 2>/dev/null) && _r_rc=0 || _r_rc=$? [ "$_r_rc" = 63 ] && _r_rc=0 _r_code=${_r_out%% *}; _r_ct=${_r_out#* } case "$_r_code" in ''|*[!0-9]*) _r_code=0 ;; esac case "$_r_out" in *' '*) ;; *) _r_ct='' ;; esac # 📌 NO 405/501 CARVE-OUT HERE, DELIBERATELY, and the asymmetry with the HEAD # arm is the point. On HEAD a 405 is about the METHOD and says nothing about # the artifact. On the range GET the request named the artifact, so any 4xx IS # an answer about it: a 416 from a zero-length object means the file exists and # is unusable, which is what status 2 says. If a future edit makes the two arms # match "for consistency", it will be re-introducing the bug the HEAD carve-out # fixed, backwards. [ "$_r_rc" = 37 ] && return 3 if [ "$_r_rc" = 0 ] || [ "$_r_code" -ge 400 ]; then _r_answered=1; fi _reachable_is_download "$_r_rc" "$_r_ct" && return 0 # 🛑 TWO DIFFERENT FAILURES, TWO DIFFERENT STATUSES, because they need # different sentences. rc 0 here means the origin ANSWERED and served # something textual. Anything else means the request did not complete at all. # # ⚠️ STATUS 2 HAS TWO CAUSES AND THIS LAYER CANNOT TELL THEM APART, so the # sentence must not pick one. A half-published CDN and an intercepting network # (captive portal, corporate proxy block page, ISP NXDOMAIN redirect) BOTH # answer 200 with text/html, which is byte-for-byte the same signature here. # An earlier version of this named only the CDN and told portal users to wait # for a release that was already published, which is the same class of wrong # advice as the "check your connection" it replaced, pointed the other way. # # ⚠️ Every caller uses `!`, `&&` or a `!= 0` test, all of which treat 1 and 2 # identically, so this changes no control flow anywhere. It only lets a caller # pick its sentence. [ "$_r_answered" = 1 ] && return 2 # 🛑 AN HTTP ERROR IS ALSO THE SERVER ANSWERING, AND rc ALONE MISSES IT. `-f` # makes curl exit non-zero on a 4xx even though the request COMPLETED, so # testing rc covered only origins whose error page answers 2xx (this site's # 404 replies 206 with its own HTML, which is why the arms passed). S3, R2 and # GitHub Releases return a HARD 404 for a not-yet-published object, the most # common half-published shape, and those fell through to "check your internet # connection". Measured: a GitHub Releases 404 gives exit 56, http_code 404. return 1 } # One copy of the refusal, because it carries four lines of user-facing text and # now branches three ways. It was duplicated verbatim in fetch_tmux and # install_kosmos, policed by a byte-identity assertion in the suite; a helper # makes that assertion unnecessary rather than load-bearing, and a wording edit # can no longer land in one caller and not the other. # # $1 = the status reachable() returned, $2 = the url. _reachable_refuse() { case "$1" in 3) info "the download at $2 is not there" info "That path does not exist or cannot be read. Check the address it is installing from." ;; 2) # NOT "could not reach": the origin answered. Saying both contradicts itself. info "the download at $2 is not usable" info "The address it is downloading from did not give an installable file." info "The release may still be publishing, something on your network may be intercepting the request, or the address may be wrong." info "Try again in a few minutes; if it keeps happening, check the address." ;; *) info "could not reach the download at $2" info "Check your internet connection and paste the install line again; it is safe to re-run." ;; esac } # ⚠️ FETCHED INTO A FRESH STAGE AND SWAPPED, never merged over what is there. # Merging an update over an old tree keeps files the new version deleted, and # a half-failed copy leaves a tree that LOOKS installed. The swap means the # destination is only ever a complete old version or a complete new one. # ⚠️ Sweep only the DEAD runs' stages (#236). The wildcard swept every # sibling stage, including one a concurrently RUNNING install was mid-download # into -- two overlapping installs (the install suite, or two accounts on one # Mac) destroyed each other's staging. Each stage ends in the pid that made # it; a stage whose pid is alive belongs to someone and is left alone. A pid # that is not a number is old junk and goes. PID reuse can spare a leftover # until the next sweep, which costs disk for a day, not a download. sweep_dead_stages() { for _stg in "$@"; do [ -e "$_stg" ] || continue _spid="${_stg##*.}" case "$_spid" in *[!0-9]*|'') rm -rf "$_stg" 2>/dev/null || true ;; *) kill -0 "$_spid" 2>/dev/null || rm -rf "$_stg" 2>/dev/null || true ;; esac done } fetch_tmux() { local dest="$1" local stage="$dest.stage.$$" # Sweep leftovers from interrupted PREVIOUS attempts (each run stages # under a fresh $$, so an interrupt -- not a failure path -- accumulates # ~130MB per Ctrl-C otherwise, invisibly, forever). Dead runs only (#236). sweep_dead_stages "$dest".stage.* # ⚠️ EVERY failure path removes the stage. Returning without cleanup left a # partial stage directory behind per attempt (a new $$ each run), so a # flaky connection accumulated half-downloads in the user's install. rm -rf "$stage" mkdir -p "$stage" || { rm -rf "$stage"; return 1; } if [ -n "${KOSMOS_TMUX_SRC:-}" ]; then info "using local copy: $KOSMOS_TMUX_SRC" [ -d "$KOSMOS_TMUX_SRC" ] || { rm -rf "$stage"; return 1; } cp -R "$KOSMOS_TMUX_SRC/." "$stage/" || { rm -rf "$stage"; return 1; } else # The version-tied query is the same cache-buster the kosmos fetch # carries: one URL across releases invites a cache to answer with # the past. tmux changes rarely, which makes a stale copy HARDER to # notice, not safer. local url="$KOSMOS_RELEASE_BASE/tmux-$ARCH.tar.gz" shaurl="" if [ -n "${BUST:-}" ]; then url="$KOSMOS_RELEASE_BASE/tmux-$ARCH.tar.gz?v=${TARGET_VERSION:-$$}" shaurl="$KOSMOS_RELEASE_BASE/tmux-$ARCH.tar.gz.sha256?v=${TARGET_VERSION:-$$}" else shaurl="$url.sha256" fi # A reachability probe first, so the two failures a launch-day install # actually hits (no network, a half-published CDN) refuse in a sentence # instead of a curl error code. The real download keeps its progress # bar, which lives on stderr and cannot be silenced without losing it. local _r_why=0; reachable "$url" || _r_why=$? if [ "$_r_why" != 0 ]; then _reachable_refuse "$_r_why" "$url" rm -rf "$stage"; return 1 fi info "downloading from $url" # ⚠️ Progress is ON. `curl -fsSL` is silent, and several minutes of nothing # is the failure this whole file is written against. curl -fL --progress-bar "$url" -o "$stage/tmux.tar.gz" || { rm -rf "$stage"; return 1; } verify_download "$stage/tmux.tar.gz" "$url" "$shaurl" || { rm -rf "$stage"; return 1; } tar -xzf "$stage/tmux.tar.gz" -C "$stage" || { rm -rf "$stage"; return 1; } rm -f "$stage/tmux.tar.gz" fi [ -f "$stage/bin/tmux" ] && [ -x "$stage/bin/tmux" ] || { rm -rf "$stage"; return 1; } # ⚠️ VERIFY THE THING WE JUST PLACED, rather than assuming the copy worked. # An arm64 binary with a broken signature does not run at all, and the failure # is silent and baffling. Better to say so here than to have the board come up # empty later with no explanation. if ! codesign -v "$stage/bin/tmux" 2>/dev/null; then info "the copy of tmux did not arrive intact" rm -rf "$stage" return 1 fi # ⚠️ AND VERIFY IT RUNS ON THIS MAC, the same check the Node runtime gets # at build time. A binary built against a newer macOS than this one loads # nothing and says nothing; without this line the first symptom is a board # that reads every agent as unknown, which nobody would ever trace to dyld. if ! "$stage/bin/tmux" -V >/dev/null 2>&1; then info "the copy of tmux will not run on this computer." info "That is a problem with the download itself, not with your Mac or your network; trying again will not fix it. We need to publish a corrected download." rm -rf "$stage" return 1 fi rm -rf "$dest" || { rm -rf "$stage"; return 1; } mv "$stage" "$dest" || { rm -rf "$stage"; return 1; } return 0 } install_kosmos() { local dest="$1" local stage="$dest/.kosmos.stage.$$" sweep_dead_stages "$dest"/.kosmos.stage.* rm -rf "$stage" mkdir -p "$stage" || { rm -rf "$stage"; return 1; } local from_network=no if [ -n "${KOSMOS_SRC:-}" ]; then info "using local copy: $KOSMOS_SRC" [ -d "$KOSMOS_SRC" ] || { rm -rf "$stage"; return 1; } cp -R "$KOSMOS_SRC/." "$stage/" || { rm -rf "$stage"; return 1; } else from_network=yes # ⚠️ THE BYTES MUST BE THE POINTER'S VERSION. The plain name is one # URL across every release, so any cache between this Mac and the # host can satisfy it with LAST release's tarball and its matching # checksum, and the swap below would then install old bytes while # reporting success (measured on Josh's machine, 2026-08-24: a # completed update log over a disk still holding the prior version). # The versioned name cannot collide across releases; where it is not # published yet, the plain name is fetched with a version-tied # cache-busting query, which a cache treats as a fresh resource. local url="" shaurl="" if [ -n "${TARGET_VERSION:-}" ] && reachable "$KOSMOS_RELEASE_BASE/kosmos-$TARGET_VERSION-$ARCH.tar.gz"; then url="$KOSMOS_RELEASE_BASE/kosmos-$TARGET_VERSION-$ARCH.tar.gz" shaurl="$url.sha256" elif [ -n "${BUST:-}" ]; then url="$KOSMOS_RELEASE_BASE/kosmos-$ARCH.tar.gz?v=${TARGET_VERSION:-$$}" shaurl="$KOSMOS_RELEASE_BASE/kosmos-$ARCH.tar.gz.sha256?v=${TARGET_VERSION:-$$}" else url="$KOSMOS_RELEASE_BASE/kosmos-$ARCH.tar.gz" shaurl="$url.sha256" fi local _r_why=0; reachable "$url" || _r_why=$? if [ "$_r_why" != 0 ]; then _reachable_refuse "$_r_why" "$url" rm -rf "$stage"; return 1 fi info "downloading from $url" # kosmos#3233 (the open half of #920): emit determinate download progress # for the install page. BEST-EFFORT and fully isolated from the download -- # every step below is guarded so a failure here can neither abort nor alter # the curl, its checksum gate, or its error handling. A file:// page cannot # fetch, so we write a tiny JS file (window.__kosmosInstallProgress) the page # re-includes; there is NO server and NO runtime dependency (a server would # need node, which arrives WITH this bundle, or /usr/bin/python3, which is # only the Command Line Tools stub on a fresh box -- see the note near the # settings verify below). The page shows the SHAPE of the wait (a determinate # bar), never a megabytes figure (#920's deliberate stance). _kp_dir="$HOME/Library/Caches/Kosmos" _kp_js="$_kp_dir/install-progress.js" # Guarded assignment: under this file's set -euo pipefail a failing HEAD (a timeout, # a 4xx/5xx under -f, a host that refuses HEAD) propagates through pipefail as the # pipeline's status, so a bare assignment would abort the install here. Fall back to # an empty total -> the page keeps its indeterminate swoosh. Same idiom the # reachable()/_kosmos_data_root() comments document for this file. # -m 5 (not 15): reachable() a few lines up already round-tripped this exact $url and # succeeded, so this HEAD answers in ~1 RTT in practice; the short cap bounds the # pathological "answered the probe, hangs on the second HEAD" host so it cannot add a # visible stall before the download starts. _kp_total=$(curl -fsIL -m 5 "$url" 2>/dev/null \ | awk 'tolower($1)=="content-length:"{v=$2} END{gsub(/[^0-9]/,"",v);print v}') || _kp_total="" case "$_kp_total" in ''|*[!0-9]*) _kp_total="";; esac _kp_emit() { # $1 = phase; best-effort, always returns 0 [ -d "$_kp_dir" ] || mkdir -p "$_kp_dir" 2>/dev/null || return 0 _kp_b=$(stat -f%z "$stage/kosmos.tar.gz" 2>/dev/null || echo 0) case "$_kp_b" in ''|*[!0-9]*) _kp_b=0;; esac # case, not `[ -n ] && ...`: the AND-list returns 1 when total is empty (the # common no-content-length case), which under set -e -- active inside the # background watcher subshell -- would abort the watcher before the write. _kp_t=null; case "$_kp_total" in ?*) _kp_t="$_kp_total";; esac _kp_tmp="$_kp_js.$$.tmp" # `|| true` so a failed write cannot abort under set -e either: the function's # contract is best-effort and always returns 0, so it can never take the install down. printf 'window.__kosmosInstallProgress={bytes:%s,total:%s,phase:"%s",ts:%s};\n' \ "$_kp_b" "$_kp_t" "$1" "$(date +%s 2>/dev/null || echo 0)" > "$_kp_tmp" 2>/dev/null \ && mv -f "$_kp_tmp" "$_kp_js" 2>/dev/null || true return 0 } _kp_sentinel="$stage/.kp-active" _kp_ppid=$$ : > "$_kp_sentinel" 2>/dev/null || true # Bounded watcher: exits when the sentinel is removed (normal teardown below) OR when # its parent -- this installer -- has died, so a hard `kill -9` of setup.sh cannot # orphan a process that rewrites install-progress.js once a second forever. kill -0 # tests liveness without signalling; $_kp_ppid is captured before the fork (in a # backgrounded ( ) subshell $$ is the parent's PID, so it is captured explicitly). ( while [ -f "$_kp_sentinel" ] && kill -0 "$_kp_ppid" 2>/dev/null; do _kp_emit downloading; sleep 1; done ) & _kp_watcher=$! curl -fL --progress-bar "$url" -o "$stage/kosmos.tar.gz" \ || { rm -f "$_kp_sentinel" 2>/dev/null || true; kill "$_kp_watcher" 2>/dev/null || true; rm -rf "$stage"; return 1; } # Guarded teardown: killing an already-exited watcher, and wait returning the # watcher's 143 (128+SIGTERM), are non-zero trailing simple commands that would # abort under set -e. || true keeps this isolation INTRINSIC, not merely inherited # from the `install_kosmos ... || die` call site that suspends errexit today. rm -f "$_kp_sentinel" 2>/dev/null || true; kill "$_kp_watcher" 2>/dev/null || true; wait "$_kp_watcher" 2>/dev/null || true _kp_emit downloaded verify_download "$stage/kosmos.tar.gz" "$url" "$shaurl" || { rm -rf "$stage"; return 1; } _kp_emit installing tar -xzf "$stage/kosmos.tar.gz" -C "$stage" || { rm -rf "$stage"; return 1; } rm -f "$stage/kosmos.tar.gz" fi # ⚠️ THE STAGE IS VERIFIED, THEN SWAPPED. On the update path the old # bundle already satisfies checks against $dest, so a failed copy used to # read as a successful update: the check must look at what just arrived, # never at what was already there. Only the bundle's three components are # replaced; tmux/, logs/ and the pidfile are the machine's own state. # ⚠️ Three renames, not one, so there IS a small window where an interrupt # leaves part-old, part-new -- stated rather than claimed away. The board # is stopped during the swap, every rename is same-filesystem, and the # recovery is the installer's own re-run, which `kosmos start` names when # the tree is incomplete. [ -f "$stage/bin/kosmos" ] && [ -x "$stage/bin/kosmos" ] || { rm -rf "$stage"; return 1; } [ -f "$stage/runtime/bin/node" ] && [ -x "$stage/runtime/bin/node" ] || { rm -rf "$stage"; return 1; } [ -f "$stage/app/server.js" ] || { rm -rf "$stage"; return 1; } [ -f "$stage/app/web/index.html" ] || { rm -rf "$stage"; return 1; } # The Plus connector (#583) rides in the bundle; a Kosmos without it installs # fine and then cannot turn Plus on, so a missing one is a broken download. [ -f "$stage/app/bin/kosmos-tunnel" ] && [ -x "$stage/app/bin/kosmos-tunnel" ] || { rm -rf "$stage"; return 1; } # The runtime must RUN here, the same probe the tmux bundle gets: a # binary that will not load fails silently and baffling, and the floor # gate upstream makes that unlikely, not impossible. if ! "$stage/runtime/bin/node" --version >/dev/null 2>&1; then info "the runtime will not run on this computer" rm -rf "$stage" return 1 fi local part for part in bin app runtime; do rm -rf "$dest/$part" || { rm -rf "$stage"; return 1; } mv "$stage/$part" "$dest/$part" || { rm -rf "$stage"; return 1; } done # The bundle's VERSION record rides along (what shipped, traceable to a # binary); optional so an older bundle without one still installs. if [ -f "$stage/VERSION" ]; then rm -f "$dest/VERSION" mv "$stage/VERSION" "$dest/VERSION" || { rm -rf "$stage"; return 1; } fi rm -rf "$stage" # 🛑 THE READ-BACK IS THE PROOF. Every claim above is about what this # run DID; this is the only line that looks at what the destination # HOLDS. A landed version that differs from the run's target means some # cache served old bytes whatever the transport, and the one honest # outcome is failure in a sentence, never "installed ... done" over the # previous release. Unknown landed version with a known target is # reported, not fatal: an older bundle without the field still installs. local landed="" landed="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$dest/app/package.json" 2>/dev/null | head -1)" # KOSMOS_SRC is somebody explicitly choosing their bytes (a thumb drive, # a harness); the pointer has no authority over that choice, so the # assertion holds only for bytes the network delivered. if [ "$from_network" = yes ] && [ -n "${TARGET_VERSION:-}" ] && [ -n "$landed" ] && [ "$landed" != "$TARGET_VERSION" ]; then info "the release pointer says $TARGET_VERSION, but the files that landed are $landed." info "A cache between this computer and the download host served an old copy. Wait a minute, then paste the install line again." return 1 fi info "on disk now: Kosmos ${landed:-(version unrecorded in this bundle)}" return 0 } # ---- how it talks ----------------------------------------------------------- # ⚠️ Plain sentences, no jargon, no filenames the reader did not choose. The one # screen a non-technical person cannot get past is the one written for somebody # else. step() { printf '\n %s\n' "$*"; } info() { printf ' %s\n' "$*"; } ok() { printf ' done\n'; } die() { printf '\n Something went wrong.\n %s\n\n' "$*" >&2 [ -f "$LOG" ] && printf ' The details are in %s\n\n' "$LOG" >&2 exit 1 } # Everything also goes to a log, so one run produces a transcript rather than a # memory of what happened. That is what makes the clean-machine test worth # something afterwards. # # ⚠️ A FIFO AND tee, NOT `exec > >(tee ...)`. The page tells people to pipe # this into `sh`, and macOS sh is bash in POSIX mode, where process # substitution is a SYNTAX ERROR: the exact line the marketing page hands out # died on line one of real use. Caught by running the script with sh, the way # a user actually will, instead of with bash, the way its author did. The # fifo spelling is plain POSIX and behaves identically; it is unlinked as # soon as both ends are open, so nothing is left behind. start_log() { mkdir -p "$LOG_DIR" || die "Could not create $KOSMOS_HOME. Check that your home folder is writable." # ⚠️ EVERY FILE LINE CARRIES THIS RUN'S ID (#237). The header lands in the # file synchronously while the body drains through a background reader, so # two overlapping runs interleave -- invisibly, because the text still reads # as ordered. Two readers independently misread one 4KB log from adjacency # alone, and this file is the one thing we ask a stranger to send us. The # console stays untagged (the person watching needs no run ids); only the # file, where runs can mix, says which line belongs to whom. printf '\n=== kosmos install %s · run %s ===\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >> "$LOG" _pipe="$LOG_DIR/.log.pipe.$$" rm -f "$_pipe" if mkfifo "$_pipe" 2>/dev/null; then # awk, not tee: one reader, two shapes. `print` goes to the console # verbatim; the file copy is prefixed with the run id. fflush both ways, # or the file trails the console by a buffer and a crash loses the tail. awk -v id="$$" -v logfile="$LOG" '{ print "[" id "] " $0 >> logfile; fflush(logfile); print; fflush() }' < "$_pipe" & exec > "$_pipe" 2>&1 rm -f "$_pipe" fi # No fifo (exotic filesystem): the install still narrates on screen, it # just loses the file transcript. Never fail the install for the log. # ⚠️ NO fd IS SAVED HERE. An `exec 3>&1` looked tidy and was never read; # its only effect was to be inherited by every child, which is exactly the # descriptor leak that once held a curl | sh install open forever. } # ---- uninstall -------------------------------------------------------------- # (Uninstall narrates to the screen only: its file transcript would live in # the very folder being deleted, and tee holding an unlinked file preserves # nothing. The screen is the record here, deliberately.) # ─── the data root, defined ONCE (#1511) ────────────────────────────────────── # # 🛑 THIS FILE HELD THE THIRD DEFINITION OF THE DATA ROOT, AND IT IS THE ONE THAT # DELETES. `engine/store.js` has `dataRootFor(platform, home, env)` as the single # source, #570 made it one function and #1443 made every consumer resolve through # it, and this script could not reach any of that, so the literal # `$HOME/Library/Application Support` was written out at each site that needed it. # # 🔑 WHAT THIS HELPER IS: the shell's answer to the SAME question `dataRootFor` # answers, in one place instead of several, so a future uninstaller for another # platform copies a named thing rather than a scattered literal. On macOS the two # agree exactly for the default input, measured: both give # `$HOME/Library/Application Support/AgentWorkforce`. On an override carrying # `./` or `..` they agree on the DIRECTORY and not on the string (node normalises, # the shell squeezes slashes only), which is why the one captured value, and not # a second derivation, feeds every comparison below. # # ⚠️ IT PREFERS THE PRODUCT'S OWN ANSWER WHEN THE INSTALL CAN GIVE ONE, AND THAT # IS OFTEN NOT POSSIBLE. An uninstall runs against whatever version is installed, # and `dataRootFor` only exists from #570 onward. Measured on a real 0.2.36 # install on this machine: the runtime and `app/engine/store.js` are both present # and `dataRootFor` is NOT exported, so the consult correctly falls through. For # every install older than #570 the literal IS the path, not a safety net, and # writing it any other way would be a claim this cannot support. # # 🛑 AND IT MUST BE CALLED BEFORE `rm -rf "$KOSMOS_HOME"`, which is why it is # called EXACTLY ONCE, in `uninstall()` before anything is removed, into `_support`, and every # consumer reads that one value: that `rm` deletes `runtime/bin/node`, and the # supervisor and litter removals need the answer hundreds of lines afterwards. # ⚠️ The first version of this captured it at each consumer instead, and the one # after the `rm` was asking an interpreter it had just removed, so it silently # got the literal while the `remote/` removal above it got the product's answer. # Two answers in one run is the inverse of this helper's purpose. The test # asserts the single call site and its position, because a second call is the # natural thing to add and it re-opens exactly that. _kosmos_data_root() { _kdr="" if [ -f "$KOSMOS_HOME/runtime/bin/node" ] && [ -x "$KOSMOS_HOME/runtime/bin/node" ] \ && [ -f "$KOSMOS_HOME/app/engine/store.js" ]; then # ⚠️ BOUNDED. This runs whatever store.js is INSTALLED, and a require that never # returns would hang the uninstall silently at the capture, which this file # names as the worst outcome (a hang with no sentence). macOS has no `timeout`, # so a shell watchdog kills the consult after KOSMOS_DATA_ROOT_CONSULT_SECONDS # (default 10; the test sets it low; a non-numeric value falls back to 10) and a # killed consult falls through to the literal exactly like the exit-3 path. A # JS-side timer cannot do this: a synchronous hang blocks the loop that would # fire it. # 🛑 THE WATCHDOG POLLS AND EXITS BY ITSELF. The first version was `sleep N; kill`, # and it left a process behind on EVERY outcome: on success the `sleep` was # orphaned for the rest of N, and on a non-zero consult exit `wait` aborted this # subshell under set -e before the watchdog was killed, so it outlived the # uninstall and fired at a dead pid. A loop on `kill -0` ends the moment node does. _kdr_secs="${KOSMOS_DATA_ROOT_CONSULT_SECONDS:-10}" case "$_kdr_secs" in ''|*[!0-9]*) _kdr_secs=10 ;; esac _kdr="$( KOSMOS_STORE="$KOSMOS_HOME/app/engine/store.js" "$KOSMOS_HOME/runtime/bin/node" -e ' const s = require(process.env.KOSMOS_STORE); if (typeof s.dataRootFor !== "function") process.exit(3); process.stdout.write(String(s.dataRootFor(process.platform, require("os").homedir(), process.env))); ' 2>/dev/null & _kdr_pid=$! ( _n=0; while kill -0 "$_kdr_pid" 2>/dev/null; do _n=$((_n+1)); [ "$_n" -gt "$_kdr_secs" ] && kill "$_kdr_pid" 2>/dev/null; sleep 1; done ) >/dev/null 2>&1 & _kdr_rc=0; wait "$_kdr_pid" 2>/dev/null || _kdr_rc=$? exit "$_kdr_rc" )" || _kdr="" fi case "$_kdr" in /*) ;; *) # The fallback, normalised the way the guard below normalises its own # operands (`//` squeezed, trailing `/` dropped), so the two derivations # agree on the string and not only on the directory. ⚠️ A consult answer that # is NOT absolute lands here too, on purpose: it is what an old or odd store.js # returns, and the literal is the right answer for that install. An ABSOLUTE # answer without the leaf is refused below instead, because that is a store.js # answering with a different contract, and guessing past it steers a delete. # #2439: the store leaf is 'Kosmos' now (engine/store.js APP), migrated from the # legacy 'AgentWorkforce' leaf by maybeMigrateLegacyStore() on the board's first # access. This fallback runs ONLY when store.js could not be consulted (no installed # runtime), so the migration may not have run -- match this file's own write-side # pattern (~line 3522): the legacy leaf only when it exists and the new one does not, # else the new leaf. A fresh or sandboxed home (neither leaf present) resolves to # /Kosmos, agreeing with what the consult path (dataRootFor's APP default) returns -- # before this, the fallback hardcoded /AgentWorkforce and so failed to match the # current-named jobs a post-rename install writes, orphaning them on a partial uninstall. _kdr="$(printf '%s' "${AGENT_WORKFORCE_DATA:-$HOME/Library/Application Support}" | /usr/bin/tr -s '/')" _kdr="${_kdr%/}" if [ -d "$_kdr/AgentWorkforce" ] && [ ! -d "$_kdr/Kosmos" ]; then _kdr="$_kdr/AgentWorkforce" else _kdr="$_kdr/Kosmos" fi ;; esac # 🛑 THE REFUSALS ARE ON THE RESULT, AND THERE ARE FIVE. IF YOU ADD A SIXTH, CHANGE # THE WORD FIVE. Every one is a delete that a bad input would have steered, and # each was found by construction: # 1 not absolute `AGENT_WORKFORCE_DATA=rel` deleted relative to the cwd # 2 no store leaf every rm below is bounded by the store leaf (/AgentWorkforce # OR /Kosmos -- both accepted during the #2439 migration window); # a consult answer of "/" or "$HOME" would have made "$_support/bin" # mean /bin or ~/bin. The trust boundary moved from a literal # to whatever the installed store.js returns, so the leaf is # checked here rather than assumed. (`/AgentWorkforce` or `/Kosmos` # directly under the root has the leaf and no parent, and is refused # by the same rule.) # 3 the system Library HOME="" (set -u does not catch empty), HOME=/ and # HOME=// all resolve to /Library/Application Support, which no # per-user install owns. Refused by RESULT, and compared by # DEVICE:INODE as well as by string: the folder the answer # resolves to (through a symlink at the parent OR at the leaf, # and through a case variant on a case-insensitive filesystem) # must not have the system folder as its parent. # 4 a . or .. component `/.`, `/x/..` and friends are the same folders by # other spellings, and every string comparison here is blind # to them. Refused outright rather than resolved. # 5 a shell-significant character the value is spliced into a `grep -F` # pattern that gates `launchctl bootout` and `rm -f` on every # agent job, and grep -F treats a NEWLINE in the pattern as a # pattern SEPARATOR (the same mechanism KOSMOS_HOME is refused # for at the top of this file). Quote, backtick, dollar and # backslash are refused with it, the same posture. # Under this file's `set -e` a refusal aborts the uninstall at the capture, # before any rm, with the reason on stderr where a terminal shows it. Every # resolution below is guarded with `||` so an unenterable directory produces a # sentence, not a silent abort at an assignment. _kdr_nl="$(printf '\n_')"; _kdr_nl="${_kdr_nl%_}" _kdr_canon="$_kdr" if [ -d "${_kdr%/*}" ]; then _kdr_canon="$(cd "${_kdr%/*}" 2>/dev/null && pwd -P)/${_kdr##*/}" || _kdr_canon="$_kdr" fi # The folder the answer actually names, following a leaf symlink if there is one, # and its parent's device:inode. Empty when nothing exists yet, which is fine: a # folder that does not exist has nothing under it to delete. _kdr_real=""; _kdr_parent_ino=""; _kdr_sys_ino="" if [ -d "$_kdr" ]; then _kdr_real="$(cd "$_kdr" 2>/dev/null && pwd -P)" || _kdr_real=""; fi if [ -n "$_kdr_real" ] && [ -d "${_kdr_real%/*}" ]; then _kdr_parent_ino="$(/usr/bin/stat -L -f '%d:%i' "${_kdr_real%/*}" 2>/dev/null)" || _kdr_parent_ino="" elif [ -d "${_kdr%/*}" ]; then _kdr_parent_ino="$(/usr/bin/stat -L -f '%d:%i' "${_kdr%/*}" 2>/dev/null)" || _kdr_parent_ino="" fi _kdr_sys_ino="$(/usr/bin/stat -L -f '%d:%i' "/Library/Application Support" 2>/dev/null)" || _kdr_sys_ino="" case "$_kdr" in *"$_kdr_nl"*|*\'*|*\"*|*\`*|*\$*|*\\*) _kdr_why="it carries a newline, quote, backtick, dollar or backslash, which the ownership checks below cannot be trusted with" ;; */./*|*/../*|*/.|*/..) _kdr_why="it carries a . or .. component, which is the same folder under a spelling none of the checks here can see" ;; *) if [ -n "$_kdr_sys_ino" ] && [ "$_kdr_parent_ino" = "$_kdr_sys_ino" ]; then _kdr_why="the folder it names sits in the system-wide Library, which no per-user install owns (an empty or / HOME, or a link there, resolves here)" else case "$_kdr_canon" in "/Library/Application Support/AgentWorkforce"|"/Library/Application Support/Kosmos") _kdr_why="that is the system-wide Library, which no per-user install owns (an empty or / HOME resolves here)" ;; /*/AgentWorkforce|/*/Kosmos) printf '%s' "$_kdr"; return 0 ;; /*) _kdr_why="it does not end in /AgentWorkforce or /Kosmos with a parent folder above it, the shape every removal below is bounded by" ;; *) _kdr_why="it is not an absolute path" ;; esac fi ;; esac # The offending value is printed through %s, never spliced into the format. printf 'refusing to uninstall: the data folder resolved to "%s", and %s. Check HOME and AGENT_WORKFORCE_DATA.\n' "$_kdr" "$_kdr_why" >&2 return 1 } uninstall() { step "Removing Kosmos." # Shared by the icon removals below: unregister BEFORE deleting (a -u on # an already-deleted path is likely a no-op, leaving the stale Spotlight # record the call exists to clear) and re-register on a failed removal # so a named survivor is not one Spotlight denies. Sandboxed runs never # touch the machine-global database. _lsreg=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister # ⚠️ KOSMOS_HOME_APP_DIR IS A SANDBOX SIGNAL TOO (#945): a walk built with # it alone (the migration-era variable, honoured for the app dir at the # top of this file) slipped past every gate keyed on the other two and # read, wrote and registered against the operator's real surfaces. # Measured 2026-08-26 00:36 on the build Mac. _lsreg_u() { [ -z "${KOSMOS_APP_DIR:-}${KOSMOS_SYS_APP_DIR:-}${KOSMOS_HOME_APP_DIR:-}" ] || return 0 [ -f "$_lsreg" ] && [ -x "$_lsreg" ] && "$_lsreg" -u "$1" >/dev/null 2>&1 || true } _lsreg_f() { [ -z "${KOSMOS_APP_DIR:-}${KOSMOS_SYS_APP_DIR:-}${KOSMOS_HOME_APP_DIR:-}" ] || return 0 [ -f "$_lsreg" ] && [ -x "$_lsreg" ] && "$_lsreg" -f "$1" >/dev/null 2>&1 || true } # The board first, while the command that knows how still exists: deleting # the folder under a running server leaves it serving ghosts. if [ -f "$KOSMOS_HOME/bin/kosmos" ] && [ -x "$KOSMOS_HOME/bin/kosmos" ]; then info "stopping the board" # #4466: --force on every board start/stop/restart in this installer. `kosmos` refuses an AGENT's # stop/restart of a board that answers, and an install or update run from an agent's pane is not # the agent restarting the board. An older kosmos ignores the extra word. "$KOSMOS_HOME/bin/kosmos" stop --force >/dev/null 2>&1 || true # A refused stop (a board this command did not start) is NAMED rather # than glossed: the files still come off, but an orphan process would # keep the port and answer errors from a deleted tree, so the user # hears about it and gets the way out. if curl -fsS -m 2 "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then info "note: something is still answering on port $PORT that this uninstall could not stop." info "It was not started by the kosmos command. Quit it, or restart your Mac, to finish." fi fi # ⚠️ AND THE BOARD'S OWN LOGIN JOB, which is newer than the agents' and does # not match their glob. Left behind, it is a launchd entry that runs a # deleted `kosmos` at every login forever — an orphan with a new cause, and # invisible to a person who believes they uninstalled the product. # ⚠️ SAME DERIVATION AS THE INSTALL PATH (#883), NOT THE BARE LABEL, or an # uninstall of a non-default KOSMOS_HOME install would look for # `com.kosmos.board.plist`, never find the suffixed file the install path # actually wrote, and leave it registered forever -- precisely the orphan # this whole block exists to prevent, just for the label instead of the # file. `_kosmos_home_default` matches the install path's own definition # exactly (both compare against the real, unsandboxed default), including # the SAME slash-normalization $KOSMOS_HOME itself already went through a # few lines up: a raw, un-normalized $HOME/.local/share/kosmos would not # string-equal a normalized $KOSMOS_HOME on a $HOME carrying a trailing or # doubled slash, which this file's own header already measured once # ("a trailing slash on $HOME made every ownership and board proof in # this file use //-flavored paths") -- the exact bug class, just for this # new comparison instead of the ones that comment already fixed. _kosmos_home_default="$(printf '%s' "$HOME/.local/share/kosmos" | /usr/bin/tr -s '/')" _kosmos_home_default="${_kosmos_home_default%/}" # 🔑 SAME DERIVATION AS THE INSTALL PATH (#883), NOW FOR THE DATA ROOT TOO, # NOT JUST THE LABEL ABOVE (#924). The label duplication above already # protects the launchd plist; it does nothing for `_remote_state` and # `_support` below, which key on AGENT_WORKFORCE_DATA. A caller who ran # this uninstall with KOSMOS_HOME set (a sandboxed walk) but # AGENT_WORKFORCE_DATA unset fell through to # `$HOME/Library/Application Support` -- the REAL, unsandboxed data root # -- while KOSMOS_HOME itself stayed correctly scoped, so the run LOOKED # targeted and swept somebody else's shared supervisor and remembered- # answer files instead. Measured live (#924): Pete's act-three uninstall # did exactly this. Caller's explicit choice still always wins, same as # every other ${VAR:-default} in this file. if [ -z "${AGENT_WORKFORCE_DATA:-}" ] && [ "$KOSMOS_HOME" != "$_kosmos_home_default" ]; then export AGENT_WORKFORCE_DATA="$KOSMOS_HOME/data" fi # 🔒 THE BELT, NOT JUST THE BUCKLE. The derivation above is the fix; this # is defense in depth for the day it doesn't fire (a future reorder, an # env quirk, a caller setting AGENT_WORKFORCE_DATA to the real path by # hand while also sandboxing KOSMOS_HOME by mistake). Same proof the # launchd label above already demands of itself: a non-default KOSMOS_HOME # must never resolve to the DEFAULT Application Support. If it somehow # does, refuse to touch it rather than silently sweep a different # install's shared data. # 🛑 THIS PAIR IS DELIBERATELY NOT ROUTED THROUGH `_kosmos_data_root` (#1511), # and it is not an oversight to tidy later. Two reasons, both load-bearing: # # 1. This guard COMPARES the two strings, so its correctness depends on both # being produced the same way. Route one through a different derivation and # the comparison can stop matching on formatting alone, which does not fail # loudly: it SILENTLY DISABLES the refusal below, on a delete path. # 2. `_default_support` deliberately wants the REAL, unsandboxed default and # must IGNORE AGENT_WORKFORCE_DATA, which is the opposite of what the helper # is for. # # And these are BASE paths, without the `/AgentWorkforce` component that # `dataRootFor` returns. Feeding it here would need the last component stripped # back off, which is a FOURTH derivation rather than one fewer. _default_support="$(printf '%s' "$HOME/Library/Application Support" | /usr/bin/tr -s '/')" _default_support="${_default_support%/}" _resolved_support="$(printf '%s' "${AGENT_WORKFORCE_DATA:-$HOME/Library/Application Support}" | /usr/bin/tr -s '/')" _resolved_support="${_resolved_support%/}" if [ "$KOSMOS_HOME" != "$_kosmos_home_default" ] && [ "$_resolved_support" = "$_default_support" ]; then die "refusing to touch $_default_support: this uninstall is for a sandboxed install ($KOSMOS_HOME), and the real Application Support folder is not part of it. If you meant to remove the real install, run --uninstall with KOSMOS_HOME unset. If you meant to remove the sandboxed install, point AGENT_WORKFORCE_DATA at its own data root instead of the real one." fi # 🔑 THE DATA ROOT, RESOLVED ONCE, BEFORE ANYTHING IS DELETED (#1511). Every # consumer below (the Plus key, the shared supervisor, the remembered answers, # the litter sweep, the closing sentence) reads this variable and none calls # the helper again: after `rm -rf "$KOSMOS_HOME"` the interpreter it consults # is gone and a fresh call would quietly return the literal instead. _support="$(_kosmos_data_root)" _board_label=com.kosmos.board if [ "$KOSMOS_HOME" != "$_kosmos_home_default" ]; then _board_label="com.kosmos.board.$(printf '%s' "$KOSMOS_HOME" | shasum -a 256 | cut -c1-8)" fi _board_plist="${AGENT_WORKFORCE_LAUNCH:-$HOME/Library/LaunchAgents}/$_board_label.plist" if [ -f "$_board_plist" ]; then info "removing the login job for the board" # enable before bootout, for the same reason the agents' loop below does # it: a standing per-user `disable` override outlives the plist and would # silently refuse to start a reinstalled Kosmos. if [ -z "${AGENT_WORKFORCE_LAUNCH:-}" ]; then /bin/launchctl enable "gui/$(/usr/bin/id -u)/$_board_label" 2>/dev/null || true /bin/launchctl bootout "gui/$(/usr/bin/id -u)/$_board_label" 2>/dev/null || true fi rm -f "$_board_plist" fi # #2955: remove the board WATCHDOG login job too, the same way and for the same # reason as the board job just above. Without this, --uninstall leaves a watchdog # LaunchAgent behind that wakes on its interval and keeps trying to `kosmos start` # a board whose files this uninstall just deleted -- the exact orphan --uninstall # exists to prevent. Done BEFORE the orphan sweep below so this install's own # watchdog plist is already gone and cannot be double-handled by the glob. _wd_label=com.kosmos.board.watchdog if [ "$KOSMOS_HOME" != "$_kosmos_home_default" ]; then _wd_label="com.kosmos.board.watchdog.$(printf '%s' "$KOSMOS_HOME" | shasum -a 256 | cut -c1-8)" fi _wd_plist="${AGENT_WORKFORCE_LAUNCH:-$HOME/Library/LaunchAgents}/$_wd_label.plist" if [ -f "$_wd_plist" ]; then info "removing the board watchdog login job" if [ -z "${AGENT_WORKFORCE_LAUNCH:-}" ]; then /bin/launchctl enable "gui/$(/usr/bin/id -u)/$_wd_label" 2>/dev/null || true /bin/launchctl bootout "gui/$(/usr/bin/id -u)/$_wd_label" 2>/dev/null || true fi rm -f "$_wd_plist" fi # ⚠️ #918: EVERY DISTINCT KOSMOS_HOME GETS ITS OWN PERMANENT LABEL, per # #883's own fix above (a hash suffix whenever KOSMOS_HOME is non-default), # and nothing ever swept one whose KOSMOS_HOME later vanished -- a walk # convention that deletes its scratch directory directly, rather than # running --uninstall against that exact KOSMOS_HOME, leaves the label # registered forever, invisible to anyone who believes the scratch # install is gone. This uninstall is already touching launchd for its OWN # label (just above); sweep every OTHER board label at the same time. # By this point THIS install's own plist is already removed (the block # above), so it can never appear in the glob below and be double-handled. _sweep_dir="${AGENT_WORKFORCE_LAUNCH:-$HOME/Library/LaunchAgents}" if [ -d "$_sweep_dir" ]; then _uid="$(/usr/bin/id -u)" for _orphan_plist in "$_sweep_dir"/com.kosmos.board.*.plist; do # Matches this file's own sibling convention: the agents-sweep loop # just below uses the same `[ -e ]` idiom for the same no-match guard. [ -e "$_orphan_plist" ] || continue # ⚠️ THE BARE DEFAULT LABEL NEVER MATCHES THIS GLOB. `com.kosmos.board.plist` # has no fourth dot-segment between "board." and the ".plist" suffix # (#883's suffix is only ever added for a non-default KOSMOS_HOME), so this # loop only ever considers a SUFFIXED label -- a genuine sweep candidate, # never the one real board every normal install has. _orphan_label="$(basename "$_orphan_plist" .plist)" # #2955: the BARE-DEFAULT watchdog label (com.kosmos.board.watchdog, no #883 # hash) DOES match this glob, unlike the bare-default board label -- so the # one real watchdog every normal install has would otherwise be a sweep # candidate, protected only by the runtime liveness check below rather than by # the lexical exclusion the default board plist enjoys. Skip it explicitly to # restore that two-layer protection: it is either this install's own (already # removed above, before the sweep) or the live default install's (which must # never be swept from under it). A SUFFIXED walk watchdog still flows through # the liveness check like any other orphan. [ "$_orphan_label" = com.kosmos.board.watchdog ] && continue # KOSMOS_HOME survives in exactly one place in this plist: the second # ProgramArguments string (the install path's own heredoc, above, never # writes it into EnvironmentVariables). PlistBuddy is the standard, # already-installed macOS tool for a structured read -- a homegrown XML # parse of a plist whose exact shape could drift is exactly the kind of # fragile code this file avoids everywhere else. # ⚠️ `|| _orphan_home_bin=""`, NOT A BARE ASSIGNMENT -- this file runs # under `set -euo pipefail`. PlistBuddy exits non-zero (not just an # empty string) whenever a plist genuinely fails to read: no # `ProgramArguments` key, a truncated file, or anything not valid XML # -- all explicitly anticipated by this very block's own "a future # format, a hand-edit" comment two lines below. Without the fallback, # a failing command substitution in a plain assignment is NOT exempt # from `set -e` (verified directly: the same shape aborts the whole # script mid-function, silently, the instant it runs) -- so ANY # unrelated malformed `com.kosmos.board.*.plist` sitting in a real # person's LaunchAgents would crash their entirely normal, healthy # uninstall partway through, before the agents' jobs, `bin/kosmos` # link, PATH line, and KOSMOS_HOME itself are ever removed. Caught in # challenge-loop iteration 3: rounds 1-2 hardened what happens when # PlistBuddy SUCCEEDS but returns something unexpected; nobody had # yet tested what happens when it fails to read at all. The `|| ""` # lets that failure land in the SAME `*) continue` arm a shape # mismatch already falls into -- "leave it alone rather than guess" # becomes true for a read failure too, not just a wrong shape. _orphan_home_bin="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:1' "$_orphan_plist" 2>/dev/null)" || _orphan_home_bin="" case "$_orphan_home_bin" in # ⚠️ `*` MATCHES ZERO-WIDTH: a ProgramArguments[1] of exactly # "/bin/kosmos" (no home prefix at all) still matches `*/bin/kosmos` # and would strip to an EMPTY `_orphan_home`. That empty string then # defeats both signals below: `[ -d "" ]` reads false ("gone"), and # `dirname ""` is POSIX-defined as "." -- a directory that always # exists -- so the parent-readability guard passes unconditionally # too. Caught in challenge-loop iteration 2: a degenerate plist # (hand-edited, or corrupted) would sail through both checks that # exist specifically to refuse an uncertain read. Required absolute # up front instead: a real KOSMOS_HOME this file ever derives is # always an absolute path, so anything else is already "not shaped # like this file's own writer produced" and refused the same way # the shape-mismatch arm below already refuses one. /*/bin/kosmos) _orphan_home="${_orphan_home_bin%/bin/kosmos}" ;; # #2955: the same glob (com.kosmos.board.*.plist) also catches a deleted # walk's WATCHDOG plist, whose ProgramArguments[1] is the watchdog script # rather than the kosmos bin. Derive its home the same way (strip the # script's own suffix) so a vanished walk's watchdog is swept too, rather # than left as the same #918 orphan this loop exists to prevent. /*/app/bin/board-watchdog.sh) _orphan_home="${_orphan_home_bin%/app/bin/board-watchdog.sh}" ;; # Not shaped like this file's own writer produced (a hand-edited or # future-format plist) -- leave it alone rather than guess. *) continue ;; esac # THE SIGNAL: does that KOSMOS_HOME still exist. A live walk in # progress (its directory present, its board plist just not yet touched # by ITS OWN --uninstall) must never be booted out from under it -- this # sweep only ever removes a label whose home is confirmed gone. [ -d "$_orphan_home" ] && continue # ⚠️ "GONE" MEANS CONFIRMED GONE, NOT "WE COULD NOT SEE IT JUST NOW". # A bare `[ -d ]` alone cannot tell a genuinely-deleted home apart from # one that is merely transiently unreachable (an unmounted volume, a # network share hiccup, an ancestor directory the process briefly # cannot stat) -- and this sweep runs on EVERY uninstall, unscoped to # the one KOSMOS_HOME the caller actually named, so an unrelated # uninstall hitting that window would boot out a still-wanted install's # job on a false read. If the PARENT directory is also unreadable right # now, that is exactly the shape of a transient outage rather than a # confirmed deletion (KOSMOS_HOME's own parent does not vanish on its # own) -- treat it as "cannot tell" and leave the label alone, matching # this whole feature's own rule that an honest "we could not check" # must never be acted on as a confirmed negative. [ -d "$(dirname "$_orphan_home")" ] || continue info "removing an orphaned background job for a deleted install ($_orphan_home)" # Same "no launchctl under a sandbox" rule as this install's own label # above: AGENT_WORKFORCE_LAUNCH set means a harness pointed the plist # directory at a temp folder, and there is no real registration there # to enable/bootout -- only the file itself is sandboxed and safe to # remove under a harness. if [ -z "${AGENT_WORKFORCE_LAUNCH:-}" ]; then /bin/launchctl enable "gui/$_uid/$_orphan_label" 2>/dev/null || true /bin/launchctl bootout "gui/$_uid/$_orphan_label" 2>/dev/null || true fi rm -f "$_orphan_plist" done fi _agents_stopped=no # ⚠️ THE SYMLINK GOES BEFORE THE FOLDER, AND `-L` IS CHECKED. `-e` follows # symlinks, so once the folder was deleted the dangling link answered # "nothing there" and survived every uninstall -- the user was told Kosmos # was removed while a dead `kosmos` stayed on their PATH. Measured. if [ -e "$BIN_DIR/kosmos" ] || [ -L "$BIN_DIR/kosmos" ]; then info "removing $BIN_DIR/kosmos" rm -f "$BIN_DIR/kosmos" fi # The PATH lines the installer wrote come out with the command they # served: exactly the marker and its export, by whole-line match, and # nothing else in the person's profile. awk (not grep -v) because an # empty result is a legitimate outcome (a profile that held only our # block), not a pipeline error to branch on. _profile="${KOSMOS_PROFILE_FILE:-$HOME/.zprofile}" case "$_profile" in /*) ;; *) _profile="" ;; esac # Same sandbox gate as the install side: a harness run touches only a # profile it names explicitly, never the operator's real one. if [ -n "${KOSMOS_APP_DIR:-}${KOSMOS_SYS_APP_DIR:-}${KOSMOS_HOME_APP_DIR:-}" ] && [ -z "${KOSMOS_PROFILE_FILE:-}" ]; then _profile="" fi _marker="$PATH_MARKER" _pline="$PATH_LINE" if [ -n "$_profile" ] && [ -f "$_profile" ] && grep -qxF "$_marker" "$_profile" 2>/dev/null; then _ptmp="$(mktemp "${TMPDIR:-/tmp}/kosmos-profile.XXXXXXXXXX" 2>/dev/null || true)" # The export is matched by ADJACENCY to the marker as well as by exact # text: an uninstall run with a different KOSMOS_BIN_DIR than the # install would otherwise remove the marker and orphan the export it # explained. Only an export-PATH-shaped line right after the marker # qualifies; anything else the person wrote there is printed untouched. # The single blank line the install printed before the marker is # swallowed with it (held one line, flushed unless the marker follows), # so install/uninstall cycles do not accumulate blank lines. # ⚠️ `cat > profile` TRUNCATES before it writes, so a failure mid-write # (disk full, permissions changed under us) would leave the person's # shell profile half-gone. A sibling backup is taken first and restored # on any failure; restore uses cat too, preserving a symlinked # profile's inode. mv is deliberately not used for the same reason. _pbak="$_profile.kosmos-uninstall-backup" # ⚠️ The backup is VERIFIED (cmp) before anything mutates the profile, # and it is only ever a restore SOURCE when verified: a cp that died # partway (disk full is this block's own named threat, and the backup # is the first write) would otherwise "restore" a partial copy over # the still-intact profile -- a shrinking write that succeeds on a # full disk -- and then claim nothing changed. # ⚠️ A pre-existing backup HALTS this block. It is a previous failed # run's preserved copy, which the person was told about by name; # overwriting it with the current (possibly damaged) profile, or # rm'ing it on this run's own failure path, would destroy exactly # what that run preserved. A run may only remove a backup it created. if [ -e "$_pbak" ]; then info "note: ${_pbak##*/} already exists from an earlier run; leaving it and the kosmos PATH line alone (the line is harmless and safe to delete by hand)" rm -f "$_ptmp" 2>/dev/null || true _bak_ok=halt else _bak_ok=no if [ -n "$_ptmp" ] && cp "$_profile" "$_pbak" 2>/dev/null && cmp -s "$_profile" "$_pbak" 2>/dev/null; then _bak_ok=yes fi fi # Announced unless HALTED (a halted run must not say "removing" and # then retract it). A failed-backup run still announces the attempt # and then reports the failure, which is the honest transcript. if [ "$_bak_ok" != halt ]; then info "removing the kosmos PATH line from ${_profile##*/}" fi if [ "$_bak_ok" = halt ]; then : # said above; nothing touched, and nothing was announced elif [ "$_bak_ok" = yes ] \ && awk -v m="$_marker" -v p="$_pline" ' skip { skip=0; if ($0 == p || $0 ~ /^export PATH=".*:\$PATH"$/) next } $0 == m { skip=1; blank=0; next } { if (blank) print ""; blank=0 } $0 == "" { blank=1; next } { print } END { if (blank) print "" } ' "$_profile" > "$_ptmp" 2>/dev/null \ && cat "$_ptmp" > "$_profile" 2>/dev/null; then rm -f "$_ptmp" "$_pbak" 2>/dev/null || true elif [ "$_bak_ok" = yes ]; then # Something after the verified backup failed; put the original back, # and verify the restore too. A failed restore keeps the backup and # NAMES it -- a backup is never deleted on a failure path unless the # restore it fed verified byte-for-byte. if cat "$_pbak" > "$_profile" 2>/dev/null && cmp -s "$_pbak" "$_profile" 2>/dev/null; then rm -f "$_pbak" 2>/dev/null || true info "note: could not edit ${_profile##*/} (restored unchanged); the kosmos PATH line is harmless and safe to delete by hand" else info "note: could not edit ${_profile##*/}; an untouched copy is at ${_pbak##*/} in the same folder" fi rm -f "$_ptmp" 2>/dev/null || true else # The backup never verified, so the profile was never touched; the # partial backup is our own junk and comes off. rm -f "$_pbak" "$_ptmp" 2>/dev/null || true info "note: could not edit ${_profile##*/}; the leftover kosmos PATH line is harmless and safe to delete by hand" fi fi # ⚠️ THE AGENTS' BACKGROUND JOBS ARE STOPPED AND REMOVED. The app installs # one launchd job per agent (com.kosmos.agent.*), set to start at every # login. With Kosmos gone there is no UI left to manage them, and "left # alone" would mean invisible processes restarting forever with a manual # launchctl recipe as the only exit. The jobs are app plumbing; the # agents' FILES are user work and stay. _agents_dir="${AGENT_WORKFORCE_LAUNCH:-$HOME/Library/LaunchAgents}" # 🛑 EVERY FILE PROVES IT IS OURS BEFORE ANYTHING IS DONE TO IT (#931). # The glob above finds every Kosmos agent job in the directory, and the # directory is the REAL ~/Library/LaunchAgents whenever the caller # sandboxed KOSMOS_HOME without also pinning AGENT_WORKFORCE_LAUNCH # (Pete's release-walk convention, the same shape as #924). Before this, # a sandboxed --uninstall then booted out and deleted every real agent's # job on the machine. The board's own plist was already protected by its # KOSMOS_HOME-derived label (#883); the agents' labels are bare names, so # the proof has to come from INSIDE the file: engine/create.js's plistFor # writes this install's supervisor path into ProgramArguments, and that # path lives under the data root this uninstall resolved above # (`_support`, the ONE resolution captured before anything is removed; this # used to re-derive it from #924's base path, a second derivation that could # disagree with the product on the string for the same directory, so the # grep below missed and the job was left registered). A job that names a # different supervisor belongs to a different install and is left alone, # by name, rather than silently. _supervisor_ours="$_support/bin/agent-supervisor.sh" for _plist in "$_agents_dir"/com.kosmos.agent.*.plist; do [ -e "$_plist" ] || continue _label="$(basename "$_plist" .plist)" _name="${_label#com.kosmos.agent.}" if ! /usr/bin/grep -qF "$_supervisor_ours" "$_plist" 2>/dev/null; then info "leaving the background job for $_name: it belongs to a different Kosmos install ($_plist names another supervisor)" continue fi _agents_stopped=yes info "removing the background job for $_name" # ⚠️ enable BEFORE bootout, the order the app's own runbook uses. The # app's Remove path runs `launchctl disable`, which writes a per-user # override keyed on the LABEL that outlives the plist. Booting out and # deleting the plist while that override stands leaves a machine where # a reinstalled Kosmos creates an agent with the same name and launchd # silently refuses to start it, with nothing on disk to explain why. # ⚠️ AND ONLY OUTSIDE A SANDBOX (same escape as the board's block below): # the label came from a sandbox plist, but "gui/$uid" is the real domain, # so a sandboxed uninstall would boot out any REAL agent sharing a name. # The file removal after this is the sandboxed half and stays. if [ -z "${AGENT_WORKFORCE_LAUNCH:-}" ]; then /bin/launchctl enable "gui/$(/usr/bin/id -u)/$_label" 2>/dev/null || true /bin/launchctl bootout "gui/$(/usr/bin/id -u)/$_label" 2>/dev/null || true fi # The agent itself runs in a detached tmux session that outlives its # launchd job; with Kosmos gone it would keep running against a tmux # binary deleted out from under it. Killed BY NAME, one session per # plist found, never kill-server: on a machine with other tmux use, # the server is not ours to kill. # ⚠️ `=$_name`, NEVER the bare name: tmux target resolution falls back # to a PREFIX match, and this repo has already measured `kill-session # -t sam` killing samantha-discord (bin/agent-supervisor.sh records the # incident). The = forces an exact match, the same form every engine # call site uses. # ...and only after PROVING OWNERSHIP the way the supervisor does: the # session must carry @kosmos_agent naming itself, or a user's own # `tmux new -s notes` would die for sharing a name with an agent's # leftover plist. if [ -f "$KOSMOS_HOME/tmux/bin/tmux" ] && [ -x "$KOSMOS_HOME/tmux/bin/tmux" ]; then _owner="$("$KOSMOS_HOME/tmux/bin/tmux" show-options -t "=$_name" -v @kosmos_agent 2>/dev/null)" || _owner="" if [ "$_owner" = "$_name" ]; then "$KOSMOS_HOME/tmux/bin/tmux" kill-session -t "=$_name" 2>/dev/null || true fi fi rm -f "$_plist" done # 🛑 THE SWEEP THE PLIST LOOP CANNOT DO (#156). The loop above finds one # session per plist -- and anything that removed plists first (an earlier # wipe, a hand cleanup) leaves agents running with nothing left that can # find them. Rick: created by Kosmos, wiped, still running, back on every # board for a week. The sessions themselves carry the complete inventory: # @kosmos_agent naming the session is exactly the ownership proof the loop # already trusts, and it survives every file on disk being deleted. So the # uninstall also asks tmux directly, and kills only sessions that name # THEMSELVES ours -- a user's `tmux new -s notes` carries no option and a # borrowed name fails the equality, the same two gates as above. if [ -f "$KOSMOS_HOME/tmux/bin/tmux" ] && [ -x "$KOSMOS_HOME/tmux/bin/tmux" ]; then # 🛑 THE PIPELINE IS GUARDED, and the guard is load-bearing on EVERY # clean Mac: with no agents ever created there is no tmux server, so # `list-sessions` exits non-zero -- and under this script's pipefail # that unguarded pipeline ABORTED the whole uninstall right here, after # the kosmos command was removed and before the app, the login job and # KOSMOS_HOME were: a half-removed install on precisely the machine # with nothing to sweep. Found by the clean-machine target (tools/ # clean-machine.sh) on its first run against the served installer. # An unreadable server (a NEWER system tmux owns the socket) takes the # same path: nothing listable means nothing sweepable, and the rest of # the uninstall must still run. # Capture-then-loop, not a guarded pipeline: forgiving the WHOLE # pipeline would also silence a future unguarded command in the loop # body, truncating the sweep while the uninstall then deletes the # agents' tmux out from under them. Only list-sessions is forgiven, # and the heredoc keeps the loop in THIS shell, so _agents_stopped # actually reaches the closing message that reads it (the pipeline # form assigned it in a subshell and the message was wrong for every # machine whose only agents ran without background jobs). _slist="$("$KOSMOS_HOME/tmux/bin/tmux" list-sessions -F '#{session_name}' 2>/dev/null || true)" while IFS= read -r _sname; do [ -n "$_sname" ] || continue _owner="$("$KOSMOS_HOME/tmux/bin/tmux" show-options -t "=$_sname" -v @kosmos_agent 2>/dev/null)" || _owner="" if [ "$_owner" = "$_sname" ]; then _agents_stopped=yes info "stopping $_sname (a Kosmos agent still running with no background job)" "$KOSMOS_HOME/tmux/bin/tmux" kill-session -t "=$_sname" 2>/dev/null || true fi done </dev/null 2>&1; then info "its Plus address is retired; the name is free again after a day" else info "note: the Plus service could not be told; its address may still show on your account page until you remove it there" fi info "removing this computer's Plus key (its agents and their files are left alone)" rm -rf "$_remote_state" 2>/dev/null || true fi rm -rf "$KOSMOS_HOME" else info "note: $KOSMOS_HOME does not look like a Kosmos install, so it was left alone." fi fi # The icon goes too, or uninstall leaves a dead app that opens nothing. # BOTH default locations are swept -- installs before 2026-08-13 wrote # ~/Applications, newer ones prefer /Applications -- each bounded by the # fixed leaf name. Under the VERBATIM override only the override dir is # touched: KOSMOS_APP_DIR set means a sandbox, and a sandboxed uninstall # reaching into the machine's REAL Applications folders would delete a # real install out from under the person running the test. # (KOSMOS_SYS_APP_DIR alone does NOT sandbox the home-folder sweep in # the else-branch below; a hand-driven --uninstall using it must # override HOME too, as the harness always does.) if [ -n "${KOSMOS_APP_DIR:-}" ]; then # The SAME ownership gate as every other destructive site: the header # promise ("anything the uninstall cannot PROVE this installer # created is left alone and named") is unconditional, and the # override path is not an exemption. -e OR -L like every sibling, so # a dangling link is named rather than silently surviving. if [ -e "$APP_DIR/Kosmos.app" ] || [ -L "$APP_DIR/Kosmos.app" ]; then if bundle_is_ours "$APP_DIR/Kosmos.app"; then info "removing the Kosmos app" _lsreg_u "$APP_DIR/Kosmos.app" rm -rf "$APP_DIR/Kosmos.app" 2>/dev/null || { _lsreg_f "$APP_DIR/Kosmos.app"; info "note: could not remove $APP_DIR/Kosmos.app; drag it to the Trash to finish."; } else info "note: the Kosmos.app in $APP_DIR could not be proven to belong to this install and was left alone." fi fi for _res in "$APP_DIR"/.Kosmos.app.stage.* "$APP_DIR"/.Kosmos.app.old.*; do { [ -e "$_res" ] || [ -L "$_res" ]; } || continue if bundle_is_ours "$_res"; then rm -rf "$_res" 2>/dev/null || info "note: could not remove the leftover hidden folder $_res; drag it to the Trash to finish." else # "could not be proven": a foreign account's aside fails the grep, # but so does OUR OWN aside after its best-effort cleanup gutted # the launcher out of it (measured in the deep-locked world), and # "not created by this install" would be false there. Claim only # the failed proof. info "note: the leftover hidden folder $_res could not be proven to belong to this install and was left alone." fi done else # A hand-driven --uninstall with only the test-only KOSMOS_SYS_APP_DIR # override still sweeps the REAL home Applications folder (HOME decides # that side), and a prior reviewer proved the mistake is easy to make; # the sweep is at least named so a mis-driven run is visible. if [ -n "${KOSMOS_SYS_APP_DIR:-}" ]; then info "note: the home-folder side of this sweep uses $HOME_APP_DIR (KOSMOS_SYS_APP_DIR does not sandbox it; override HOME too in a test)" fi _sys_swept=no # The SYSTEM folder is shared between accounts, so its icon is deleted # only after PROVING OWNERSHIP the same way the tmux session kill does: # the bundle's launcher must name THIS install's KOSMOS_HOME. Without # the predicate, one account's uninstall would delete another # account's working icon. The HOME folder needs no predicate -- it is # per-user by construction. # -e OR -L, the same shape as resolve_app_dir's aliasing check: a # dangling symlink named Kosmos.app is residue too, and -d alone # leaves it invisible forever on the path whose header promises the # machine is returned to before. # (Ownership cannot be proven through a dangling link, so the refusal # note prints; that is honest, and the note names the survivor.) if [ -e "$SYS_APP_DIR/Kosmos.app" ] || [ -L "$SYS_APP_DIR/Kosmos.app" ]; then # The same anchored token as resolve_app_dir: the closing `}"` keeps # two homes in a prefix relationship from cross-matching, because # this grep is the sole gate on an rm -rf in a shared folder. if bundle_is_ours "$SYS_APP_DIR/Kosmos.app"; then info "removing the Kosmos app from $SYS_APP_DIR" # A standard user cannot always delete from /Applications; an icon # that survives is NAMED, never silently skipped. The flag feeds # the home-folder link branch below: a link is our residue only # when THIS RUN removed the bundle it pointed at. _lsreg_u "$SYS_APP_DIR/Kosmos.app" if rm -rf "$SYS_APP_DIR/Kosmos.app" 2>/dev/null; then _sys_swept=yes else _lsreg_f "$SYS_APP_DIR/Kosmos.app" info "note: could not remove $SYS_APP_DIR/Kosmos.app; drag it to the Trash to finish." fi else # "could not be proven": the failed match observed exactly that # and nothing more -- it covers another account's Kosmos, a # third-party app carrying the name, and a user's link to our # own bundle (linkness fails the proof by design). info "note: the Kosmos app in $SYS_APP_DIR could not be proven to belong to this install and was left alone." fi fi # ⚠️ Skipped when the two folders are physically the same (~/Applications # symlinked to /Applications): the ownership-checked branch above # already decided that bundle's fate, and this delete would override # its refusal through the symlink. FAIL CLOSED, the same shape as the # install-side guard: an unresolvable folder is a reason to leave the # bundle alone and say so, never a license to delete through it. _home_apps_phys="$(cd "$HOME_APP_DIR" 2>/dev/null && pwd -P)" || _home_apps_phys="" _sys_apps_phys="$(cd "$SYS_APP_DIR" 2>/dev/null && pwd -P)" || _sys_apps_phys="" # -e OR -L, the same shape as the system-folder gate above: -d follows # symlinks, so a dangling link named Kosmos.app here would survive # every uninstall in silence, against the survivor-is-NAMED rule. # # A LINK ENTRY is decided by its target, not by the tests below (which # would follow it onto whatever it points at): a link at the system # bundle this uninstall just swept is our residue and goes; any other # link was not made by this installer and is left, named. if [ -L "$HOME_APP_DIR/Kosmos.app" ]; then _lnk_target="$(readlink "$HOME_APP_DIR/Kosmos.app" 2>/dev/null)" || _lnk_target="" # BOTH conditions: pointing at our slot is not enough, because the # bundle there may have been foreign and refused two lines up (or # absent entirely), and "pointed at the removed Kosmos app" must # never print on a run that removed nothing. Measured in review: # the target-only version deleted a user's link to a refused # bundle, under two adjacent contradictory sentences. if [ "$_lnk_target" = "$SYS_APP_DIR/Kosmos.app" ] && [ "$_sys_swept" = "yes" ]; then info "removing a link that pointed at the removed Kosmos app from $HOME_APP_DIR" rm -f "$HOME_APP_DIR/Kosmos.app" 2>/dev/null || info "note: could not remove $HOME_APP_DIR/Kosmos.app; drag it to the Trash to finish." else info "note: the Kosmos.app in the Applications folder inside your home folder is a link this install did not create; it was left alone." fi elif [ -e "$HOME_APP_DIR/Kosmos.app" ]; then if [ -n "$_home_apps_phys" ] && [ -n "$_sys_apps_phys" ] && [ "$_home_apps_phys" != "$_sys_apps_phys" ]; then # The same ownership token as the system folder: uninstalling # Kosmos must not delete somebody's unrelated app that happens to # carry the name, even in the per-user folder. if bundle_is_ours "$HOME_APP_DIR/Kosmos.app"; then info "removing the Kosmos app from $HOME_APP_DIR" _lsreg_u "$HOME_APP_DIR/Kosmos.app" rm -rf "$HOME_APP_DIR/Kosmos.app" 2>/dev/null || { _lsreg_f "$HOME_APP_DIR/Kosmos.app"; info "note: could not remove $HOME_APP_DIR/Kosmos.app; drag it to the Trash to finish."; } else info "note: the Kosmos.app in the Applications folder inside your home folder was not created by this install and was left alone." fi elif [ -z "$_home_apps_phys" ]; then # The fail-closed note names the folder that actually failed the # check; blaming the home folder when the system one was the # unresolvable side would claim something never observed. (This # leg is believed unreachable -- seeing the entry already needs # the search bit cd needs -- and is kept as defense; no driving # pass is possible.) info "note: could not check the Applications folder inside your home folder; the Kosmos icon there was left alone." elif [ -z "$_sys_apps_phys" ]; then info "note: could not check $SYS_APP_DIR, so the Kosmos icon in the Applications folder inside your home folder was left alone." fi fi # Probe and stage residue from any earlier run goes too; the loop # CHECKS ITS RESULT and names any survivor, because the served header # promises this sweep and a deep-locked leftover really can refuse an # rm (measured: the best-effort version left a hidden .old folder in # the system folder under a closing line saying the machine was back # to before). The -e||-L test per entry keeps an unmatched glob # harmless. (Same accepted race as the probe sweep: a second # account's in-flight install could lose its hidden stage to this # sweep; bounded, rare.) rm -rf "$SYS_APP_DIR"/.kosmos-write-probe.* 2>/dev/null || true # ⚠️ WITH THE SAME OWNERSHIP PROOF as the visible bundle: an aside can # be another ACCOUNT'S only surviving icon (the restore-failure note # sends them to it by name), and residue that carries no provable # launcher is left and named rather than deleted, per the header's # stated bound. for _res in "$SYS_APP_DIR"/.Kosmos.app.stage.* "$SYS_APP_DIR"/.Kosmos.app.old.* \ "$HOME_APP_DIR"/.Kosmos.app.stage.* "$HOME_APP_DIR"/.Kosmos.app.old.*; do { [ -e "$_res" ] || [ -L "$_res" ]; } || continue if bundle_is_ours "$_res"; then rm -rf "$_res" 2>/dev/null || info "note: could not remove the leftover hidden folder $_res; drag it to the Trash to finish." else # "could not be proven": a foreign account's aside fails the grep, # but so does OUR OWN aside after its best-effort cleanup gutted # the launcher out of it (measured in the deep-locked world), and # "not created by this install" would be false there. Claim only # the failed proof. info "note: the leftover hidden folder $_res could not be proven to belong to this install and was left alone." fi done fi # The shared supervisor is app plumbing (the same argument as the launchd # jobs) and goes; the STORE next to it is the user's agent records and # stays, and the closing sentence names where. `_support` was resolved once at # the top of this function, before the app tree (and its interpreter) went. if [ -d "$_support/bin" ]; then info "removing the shared supervisor" rm -rf "$_support/bin" fi # 🔑 THE APP'S OWN REMEMBERED ANSWERS ARE PLUMBING TOO, THE SAME ARGUMENT AS # THE SUPERVISOR ABOVE (#891). FOUR tiny files live at the data folder's # root, each holding one "have we asked this yet" fact the app checked once # so it would not ask again: whether first run has been seen # (first-run.json), the last app version the person was shown a what's-new # for (seen-version.json), whether the "we found your existing agents" # card was dismissed for good (found-agents-dismissed.json, # engine/discover.js's DISMISS_FILE), and which individual folders somebody # answered "this isn't an agent" about (found-agents-declined.json, # discover.js's DECLINED_FILE, #1531). # # ⚠️ THE FOURTH ONE WAS ADDED WITHOUT BEING ADDED HERE, WHICH IS THE WHOLE # POINT OF WRITING THE COUNT INTO THIS COMMENT. `found-agents-declined.json` # shipped hours before this line did, so an uninstall did NOT reset declines: # a person who said "this isn't an agent", uninstalled to start over, and # reinstalled got a screen that still hid that folder and no way to know why. # That is the exact case an uninstall exists to prevent. # # 📌 SO IF YOU ADD A FIFTH, CHANGE THE WORD "FOUR". The number is here to make # a missing member visible to a reader who is not looking for one, and the # family this belongs to is enumerated in engine/discover.js too. None of them # is the # person's data -- unlike "Forget my data" (engine/forget.js), which # deliberately KEEPS first-run.json so clearing your data does not also # re-trigger onboarding, uninstall removes the whole app, so a reinstall # should start from the same blank slate a first install does. `rm -f` # (not `-rf`: these are files, and `-f` is silent when one was never # written, e.g. a person who never opened the what's-new page). rm -f "$_support/first-run.json" "$_support/seen-version.json" \ "$_support/found-agents-dismissed.json" "$_support/found-agents-declined.json" # ⚠️ Deliberately NOT removed: the user's agents' folders, their instruction # files, and anything under ~/work. Uninstalling the app must never delete # somebody's work, and an installer that cleans up too enthusiastically is # worse than one that leaves a folder behind. # Claim only what was observed: the plists were REMOVED (we removed them); # "stopped" would assert an outcome the best-effort bootout never checked. # And on a machine with no agents, say nothing about agents at all. # 🛑 OUR OWN BOOKKEEPING GOES; THE PERSON'S DATA STAYS (#1547). The status # engine writes a would-ping log into the data folder during normal running # (#1494), and the uninstall left it there -- so somebody who removed Kosmos # found our ping-logs sitting in their AgentWorkforce folder. Leave no trace # applies to what WE generated, never to what they made. # # ⚠️ BY EXACT NAME, NOT A GLOB, and that is this file's own rule rather than # caution for its own sake: every other rm here proves ownership first, and a # pattern-swept data folder is precisely how an uninstaller deletes the thing # it promised to keep. `wouldping/` is written by us and only by us; anything # we cannot name that confidently is left alone and named, per the header. # # 🛑 `$_support`, NOT A SECOND DERIVATION. AGENT_WORKFORCE_DATA is the PARENT # of the AgentWorkforce folder, not the folder itself (`engine/store.js:85` # joins APP onto it), and a first version of this block re-derived the path and # dropped that segment -- so it looked in `$AGENT_WORKFORCE_DATA/wouldping` # while the log lives in `$AGENT_WORKFORCE_DATA/AgentWorkforce/wouldping` and # the sweep silently did nothing. It only appeared to work on a fully default # install, where the fallback happens to include the segment. # ⚠️ AND `uninstall()` ITSELF EXPORTS THAT VARIABLE at the top of this function # whenever KOSMOS_HOME is non-default, so every non-default install was missed. # This file's own header states the rule that would have prevented it: two # derivations of one string is how a sweep silently stops matching what the # install wrote. `_support` is computed once, above, and is in scope here. # 🛑 SIX DIRECTORIES, AND IF YOU ADD A SEVENTH CHANGE THE WORD "SIX". The number # is here to make a missing member visible to a reader who is not looking for one, # exactly as the remembered-answer block above does, and for the same reason: that # block records that its fourth member shipped hours before it was listed there. # # 🛑 DERIVED BY SEARCH, NOT BY RECALL, AND THAT DISTINCTION IS THE WHOLE HISTORY OF # THIS BLOCK. The first version swept ONE directory and the plan called it "the only # litter I could name confidently" -- a sentence about my memory that reads as a fact # about the codebase. A reviewer found a second. The sentence was then corrected and # THE METHOD WAS NOT, so a second reviewer found four more. The list below comes from # `grep -n "store\.ROOT"` across the engine, which is the command that should have # been run first: # # REMOVED, ours: we write it, we are the only writer, and it is rebuilt on next run # downloads the provider binaries we downloaded. THE BIG ONE: connect.js # records a stranded copy costing ~281MB, so leaving this behind # while removing a JSONL ping log gets the priority exactly backwards # usage a derived cache, recomputed from transcripts # sendertokens tokens WE mint (mode 0700); leaving agent credentials on disk # after an uninstall is a residue question, not only tidiness # selfreports agent state records we append # wouldping the ping log this card was filed about # liveness agent heartbeats # # LEFT ALONE AND NAMED, per this plan's Scope rule. Not oversights: # chats, commitments the person's, by `engine/forget.js:45` -- the "Forget my # data" surface, whose own comment says adding a name # there is the only way to widen it # projects.json, profiles, attachments, messages.jsonl, messages, avatars, # you.json, you-avatar the person's work and their own profile # secrets THE PERSON'S CREDENTIALS. We wrote the files; the keys # inside are theirs, and destroying them on an uninstall # they may be reversing is not ours to decide # connect.json, removed.json, room-seen.json # records of the PERSON'S DECISIONS. We write them, so # they are arguably ours, and that is exactly why they # are named rather than swept: "we wrote it" is not the # test, "it is ours rather than theirs" is # created.jsonl, trust-writes.json, styles.json, ping.json, remote.json, # remote-status.json, github-app.json, autoupdate.json, engmode.json, # limits.json, notify.json, policy.json # OURS BY THE SAME RULE AS THE SWEPT LIST, AND # DELIBERATELY NOT SWEPT. See the note below. # # 🛑 ALREADY REMOVED ABOVE, SO DO NOT ADD THEM HERE: `first-run.json`, # `seen-version.json`, `found-agents-dismissed.json` and `found-agents-declined.json` # go at the `rm -f` of the four remembered-answer files ("the app's own remembered answers"). # 🛑 `remote/` IS DIFFERENT AND THIS ROW USED TO CALL IT SIMPLY HANDLED. It is # removed at `rm -rf "$_remote_state"`, but only inside FOUR nested conditions: KOSMOS_HOME exists, the # ownership gate passes, `remote/mac_key` exists, AND the tunnel binary is executable. # On a partial install, a second --uninstall, a bundle without the tunnel, or any run # failing the ownership gate, `remote/` SURVIVES. # ⚠️ AND IT IS THE ONE LEFTOVER THAT IS A CREDENTIAL: it holds this Mac's Plus # key. The row that was wrong was the row holding an actual key, in a table whose # justification for sweeping `sendertokens` is that leaving credentials behind is a # residue question. Stated as a CONDITION rather than a fact, because a maintainer # reads this table as a map of the folder. # 📌 Distinct from the `remote.json` and `remote-status.json` FILES, which are # left alone above; unsaid, the two files' row reads as covering the directory. # 📌 `bin/` is removed at `rm -rf "$_support/bin"` ("removing the shared supervisor"), above. # put two contradictory rulings about the same four files in one function, and the # newer one was wrong. A maintainer reading this table to decide whether a fifth # decision-record is safe would have got the wrong answer. # # 🛑 WHY THE SWEEP STOPS AT SIX RATHER THAN TAKING THE TWELVE NAMED ABOVE AS OURS. # This is a deliberate call, not an omission. Each round of widening this list has # introduced a defect: adding `liveness` and `selfreports` silently falsified the # closing sentence of the whole uninstall, because both are keyed PER AGENT and that # sentence named their folder as untouched. The six swept are directories of pure # machine output with no per-person content. The eleven are single files, several # holding settings a person set (`engmode`, `limits`, `notify`, `policy`), and each # would need its own reading of ours-versus-theirs. Naming them is free and cannot # break anything. Deleting them cannot be undone. If a later card wants them, it # should take them one at a time with that reading written down. # # 📌 AND THE LIST ABOVE WAS NOT DERIVED BY `grep store.ROOT` ALONE, BECAUSE THAT # COMMAND IS BLIND: `engine/styles.js:20` and `engine/trust.js:393` write through an # inline `require('./store').ROOT`, and `engine/create.js:1683` goes through a # `supportDir()` helper. `grep -c 'store\.ROOT' engine/styles.js` returns ZERO for a # file that writes there. Search for the WRITES (`path.join(` with a root-ish first # argument) and read them, rather than for one spelling of the root. # ⚠️ REMOVE FIRST, THEN CLAIM, which is this function's stated rule three lines up: # "Claim only what was observed". Announcing before the attempt and swallowing the # failure with `|| true` (required here, the file is `set -euo pipefail`) told the # person records were removed when a permission error meant they were not. The # neighbouring leftover-folder code ("removing the shared supervisor") already does it this way. _swept=no _swept_left="" for _litter in downloads usage sendertokens selfreports wouldping liveness; do if [ -d "$_support/$_litter" ]; then rm -rf "$_support/$_litter" 2>/dev/null || true if [ -d "$_support/$_litter" ]; then _swept_left="$_swept_left $_litter"; else _swept=yes; fi fi done # 📌 ONE LINE, IN THE PERSON'S WORDS. An earlier version said "removing Kosmos's own # wouldping records", printing a MODULE NAME to somebody uninstalling an app, once # per directory, so six removals read as more happening than had. The neighbours are # the model: "removing the shared supervisor", "stopping the board". # 🛑 "LEFTOVER FILES", NOT "RECORDS ABOUT YOUR AGENTS". Three of the six are not # records about agents at all: `downloads` is provider binaries, `usage` is a cache # derived from the person's own transcripts, `sendertokens` are minted credentials. # The biggest thing this removes was being announced as something it is not. # ⚠️ AND IT MUST NOT MENTION AGENTS, because it fires on machines that have none: # `downloads/` is written by the provider-connect flow and `usage/` from the person's # own ~/.claude dirs, both before any agent exists. The rule is stated beside the four remembered-answer files # -- on a machine with no agents, say nothing about agents at all. if [ "$_swept" = yes ]; then # ⚠️ NOT AN UNQUALIFIED PAST TENSE, AND DELIBERATELY NOT GATED ON # `_swept_left` EITHER. The closing line below was fixed to require nothing left # behind; the same fix here gives the opposite defect, six directories removed # and the transcript saying nothing at all. The wording carries it instead. info "removed Kosmos's own leftover files (your projects, conversations and sign-ins stay; see any notes below)" fi # ⚠️ NAMES WHAT SURVIVED AND WHERE, which this file's header (setup.sh:89) requires of # every leave-behind: "on the rare machine where that leaves something behind, the # sentence says what it is". Every sibling note in this function names a path; this # was the only one that named nothing. if [ -n "$_swept_left" ]; then for _litter in $_swept_left; do info "note: could not remove $_support/$_litter; it is Kosmos's own and safe to delete" done fi if [ "$_agents_stopped" = "yes" ]; then # 🛑 THIS SENTENCE WAS TRUE UNTIL THE SWEEP ABOVE EXISTED, AND THE SWEEP IS WHAT # FALSIFIED IT. It named the AgentWorkforce folder as a place agents' files were # left alone; `liveness/` and `selfreports/` are keyed PER AGENT in that folder # (`liveness/angel.json`, `selfreports/angel.jsonl`) and the sweep removes them. # A closing line on an uninstall exists to say truthfully what survived, so it # names the split rather than the folder. # 🛑 THE SECOND CLAUSE IS CONDITIONAL AND MUST STAY THAT WAY. Written flat, it # asserted the leftover files were removed even when the sweep had just FAILED and # said so four lines above, and even when there was nothing to sweep and no line was # printed at all. A closing sentence that contradicts a note in its own transcript is # worse than no closing sentence. # ⚠️ BOTH CONDITIONS. `_swept` is yes when ANY directory went, so with five removed # and one stuck it was still making the unqualified claim while a note above named # the survivor. The unqualified sentence needs nothing left behind, not something # taken. Caught by the failure-path test rather than by reading. if [ "$_swept" = yes ] && [ -z "$_swept_left" ]; then printf '\n Kosmos is removed. Your agents\047 background jobs were removed, and so were\n' printf ' Kosmos\047s own leftover files. Your agents\047 own folders, and your projects,\n' printf ' conversations and sign-ins, were left alone in\n' printf ' %s\n\n' "$_support" else printf '\n Kosmos is removed. Your agents\047 background jobs were removed; your projects,\n' printf ' conversations and sign-ins were left alone in\n' printf ' %s\n\n' "$_support" fi else printf '\n Kosmos is removed.\n\n' fi # ⚠️ Named, not removed: the install may have recorded the agent # permission setting in Claude Code's own config, but that file can carry # the person's real settings and the same key set by their own hand -- # an uninstaller cannot tell, so deleting it would overstep. The # reversibility contract is honored by NAMING what was left, per the # header's rule that anything not removed is left alone and named. if [ -f "$HOME/.claude/settings.json" ] && grep -Eq '"skipDangerousModePermissionPrompt": *true' "$HOME/.claude/settings.json" 2>/dev/null; then # ⚠️ THE VALUE, NOT THE KEY (#932): the sibling gate below learned this # first (hasTrustDialogAccepted). A key present and false, set by the # person's own hand or flipped back after install, means nothing was # left in effect and the question is already back, and every clause of # the sentence below would be wrong. printf ' One setting was left in place: skipDangerousModePermissionPrompt in\n' printf ' ~/.claude/settings.json (agents skip per-action permission prompts).\n' printf ' Delete that line there if you want the question back.\n\n' fi # #3946: the status line Kosmos adds to record weekly usage is ours by its marker, # but the bundled Node that could edit the JSON is gone by now, so it is NAMED, as # the header's rule asks. Left in place it points at files this uninstall removed, # so it records nothing any more, and it keeps the one status-line slot. # Every account Kosmos wires, not only the default: account folders are kept by this # uninstall, so their entries would otherwise be left unnamed. # Two passes over the same glob rather than one space-joined list, so a path with a # space in it is printed whole. _sl_left="" for _sl in "$HOME/.claude/settings.json" "$HOME"/.claude-*/settings.json; do [ -f "$_sl" ] && grep -q 'kosmos-statusline\.js' "$_sl" 2>/dev/null && _sl_left=yes done if [ -n "$_sl_left" ]; then printf ' Kosmos\047s status line was left in these settings files (the "statusLine"\n' printf ' entry naming kosmos-statusline.js). It records nothing now; delete that entry\n' printf ' if you want to use a status line of your own:\n' for _sl in "$HOME/.claude/settings.json" "$HOME"/.claude-*/settings.json; do [ -f "$_sl" ] && grep -q 'kosmos-statusline\.js' "$_sl" 2>/dev/null && printf ' %s\n' "$_sl" done printf '\n' fi # ⚠️ AND THE SECOND THING WE LEFT IN THAT TOOL'S CONFIG, named for exactly the # same reason. Creating an agent records that Claude Code trusts the folder # Kosmos made for it, and an uninstaller cannot tell those lines from ones the # person accepted themselves at the same paths. Deleting them would overstep; # leaving them SILENTLY would break the header's rule that anything not # removed is left alone and NAMED — which is the rule this whole block exists # to honour, and which a new leftover quietly opts out of. # # ⚠️ `grep`, not a JSON read, and the sentence is written to what grep can # actually prove: the file MENTIONS the key. By this point the bundled Node is # gone, so there is nothing here that can parse the file, and a sentence that # counted entries would be a claim this code cannot support. # 🛑 THIS PARAGRAPH USED TO END "SO THE MARKS STILL APPLY", AND #1659 MADE # THAT FALSE FOR SOME OF THE LINES BELOW. Corrected here rather than left to # contradict the sentence 84 lines down that already says so. # It was true and useful when written: a LIVE account's folder is left alone, so # its mark is not stale and opening that folder later will not ask. # What changed: the sweep now also lists `.removed-claude-*`, and NOTHING points # CLAUDE_CONFIG_DIR at a forgotten account. That is the entire point of the # rename, and engine/status.js:198 skips the prefix deliberately. So for those # lines the mark does NOT still apply, opening one of those folders WILL ask, and # the `_trust_removed` sentence below is what tells the person which case is # theirs. Two flags and two sentences on purpose: one sentence covering both # would be false for whichever half the person actually has. # (Mona Lisa, 2026-08-21; she checked the folder # fact against the notice above rather than assuming the two shapes matched.) # # ⚠️ THE PARENTHETICAL IS LOAD-BEARING, per the precedent above: a key name # shouted at somebody who has just removed the only thing that could explain # it is not a disclosure. Name it, translate it, say the undo. # 🛑 THE GATE HAS TO SUPPORT THE SENTENCE, AND THE FIRST ONE DID NOT. It was # `grep -q 'hasTrustDialogAccepted'`, which matches on essentially ANY Claude # Code config: measured on this machine, 22 entries and NONE of them lack the # key. So it fired for people Kosmos had never written a byte for, and told # them their agents' folders were recorded as trusted. # # ⚠️ TWO NARROWINGS, both cheap, and the "nothing here can parse JSON" excuse # reached neither of them. First, the value: only a `true` is a trust mark at # all (19 of the 22 here are `false`, which is Claude Code's default rather # than an answer). Second, `_agents_stopped`: the paragraph above deliberately # says nothing about agents on a machine that had none, and this block was # contradicting it three lines later — and its own sentence "those folders are # still on your machine" is only true because THAT paragraph left them there. # 🛑 #1629: EVERY CONFIG AN AGENT COULD READ, NOT JUST THE DEFAULT ONE. Claude # Code reads trust from `$CLAUDE_CONFIG_DIR/.claude.json` when that variable is # set, and Kosmos points agents at per-account config dirs (`~/.claude-*`). This # checked `$HOME/.claude.json` alone, so on a machine whose agents ran under # other accounts it said nothing while the marks it describes were sitting in a # file one directory over -- a true-sounding silence. # ⚠️ It also NAMED that one file in its sentence, so even when it did fire the # remedy pointed at the wrong place. It now names the files it actually found. _trust_marked='' # 🛑 TWO FLAGS, NOT ONE, AND THE ONE-FLAG VERSION WAS MINE. A single # `_trust_removed` gated a pair of sentences that assert OPPOSITE things: "for a # folder you still use, the mark applies" claims a LIVE mark, and the next line # claims a REMOVED one. On a machine whose ONLY marked config belongs to a # disconnected account, the first sentence is false, and the header above it is # false of every file in the list. Reachable, not theoretical: this block's own # comment records 19 of 22 configs on the fleet machine carrying `false`. # Initialised here rather than relying on `${x:-no}`, so the two read like the # list they sit beside. _trust_live=no _trust_removed=no # 🛑 FORGOTTEN ACCOUNTS TOO (#1659). Removing an account RENAMES its directory # to `.removed-claude-