#!/usr/bin/env bash # 5dive CLI installer / uninstaller # # ============================================================================ # THIS FILE DEPLOYS ON MERGE. THERE IS NO TAG BETWEEN YOU AND THE FLEET. # ============================================================================ # Everywhere else in this repo, merging is STAGING and the release cut is the # publish act. Not here. This file is fetched from `refs/heads/main` and run AS # ROOT on every box's next `--upgrade` (`src/cmd_selfupdate.sh`, `5dive uninstall`, # and the public `curl … | sudo bash`). A merge to it is live fleet-wide within # one upgrade cycle, with no tag, no cut, and no rollback point. # # So the rule everyone was taught — "merging is safe, the cut is the risky part" — # is EXACTLY BACKWARDS in this file, and nothing in a diff or a PR title says so. # That inversion is DIVE-2288; this comment is the fix for it. # # The same property holds for the six BRANCH-TARBALL fetches below (skills and # 5dive-plugins, marked `MERGE-DEPLOYS` at each site) — those land root-installed # from ANOTHER repo's mutable main, where no control in this repo can see them. # # THE COUNT, WITH ITS SCOPE AND ITS DATE, because a bare figure at an edit site is # the very thing this comment exists to prevent: # 7 live sites ON THE ROOT-INSTALLED-ON-UPGRADE RAIL — this file plus the six # branch tarballs below — as of 2026-07-30, measured against 6f5e5c9. # DIVE-2308 enumerated 9 on 2026-07-29 against 44e612f. Row 8 (the version probe # calling main HEAD "published") is CLOSED: `_published_cli_probe` now resolves # the newest release tag and fails closed. Row 9 (the `status` badge branch) is # public-by-design and off the install path. # ADJACENT AND DELIBERATELY NOT IN THE 7 — a different rail, named so the number # cannot be mistaken for the whole population: `_marketplace_base` # (src/cmd_pack.sh) and `_loops_base` (src/cmd_loop_pack.sh) each fetch another # repo's mutable main, but ON DEMAND when a user runs the verb, not root-installed # on every upgrade. # Full enumeration and its negative space (DIVE-2308, corrected 2026-07-30): # community/wiki/the-merge-deploys-population-is-scoped-to-the-box-not-to-the-repo.md # # Everything else the installer places comes through `$REPO`, which DIVE-2144 # pins to the newest release TAG. If you are adding a fetch, decide which of the # two you are in and say so at the site. # ============================================================================ # Install: curl -fsSL https://install.5dive.ai | sudo bash # Upgrade: curl -fsSL https://install.5dive.ai | sudo bash -s -- --upgrade # Uninstall: curl -fsSL https://install.5dive.ai | sudo bash -s -- --uninstall set -euo pipefail # GitHub org our repos live under. The org is being renamed # 5dive-com -> 5dive-ai (2026-06); installs must work on either side of the # rename, so probe the new org once and fall back to the old name. GH_ORG # env overrides the probe (CI, forks, air-gapped mirrors). Standalone copy # of the bundle's gh_org() — this script runs before the bundle exists. if [[ -z "${GH_ORG:-}" ]]; then if curl -fsI --max-time 8 "https://raw.githubusercontent.com/5dive-ai/5dive/main/install.sh" >/dev/null 2>&1; then GH_ORG="5dive-ai" else GH_ORG="5dive-com" fi fi # >>> DIVE-1977 pin-resolution block (extracted verbatim by tests/install_pin_sha_unit.sh) # DIVE-1977: every path under raw.githubusercontent.com//5dive/main is an # INDEPENDENT CDN object with its own cache generation, so for a window after # each release the CDN can hand back the PREVIOUS bundle next to the NEW # 5dive.sha256. Nothing is corrupt and nothing was tampered with — the two # objects simply do not describe each other — but the checksum guard in # refresh_managed_files() fails closed and blames "corrupt download or tampered # mirror", which is a security-shaped alarm raised by a cache race on an # unattended 04:00 self-update. # # So resolve the mutable `main` ref to ONE immutable commit sha here, once, and # fetch every asset from raw//. Staleness is fine: if the resolver hands # back a slightly older sha, both objects come from that one tree and the box # installs the previous release for a few minutes — a non-event. INCONSISTENCY # is the thing that is unfixable at the client, and a pinned tree cannot be # inconsistent. The guard itself stays exactly as strict as it was. # # DIVE-2144: WHAT we resolve changed, and it is the whole point of that ticket. # It used to be the tip of `main`, which made merging identical to publishing: # an unreviewed merge was live on every box within one self-update, and the # version-assign commit sat on the customer critical path (DIVE-2118/2141/2142). # Now it is the newest RELEASE TAG. Cutting a tag becomes the publish act; # merging only stages. The pin mechanism below is unchanged — same ladder, same # raw// fetch, same DIVE-1977 consistency property — only its INPUT moved. # # Two ways to get this wrong, both measured on the real 285-tag repo, both of # which succeed loudly-plausibly rather than failing: # # LEXICAL SORT. `sort | tail -1` — the form almost everyone writes — returns # v0.9.9, not v0.15.34. That ships a six-minor-version DOWNGRADE to every box # while exiting 0 with a real tag name in the log. Nothing downstream catches # it: the sha is valid, the tree is consistent, the pin is honest. Version-sort # (`sort -V`) is load-bearing, not tidiness. tests/install_pin_sha_unit.sh # proves it by mutation. # # FAILING OPEN TO /main. The old block fell back to the mutable ref when # nothing resolved. Keeping that here would invert its meaning: pre-2144 the # fallback was a PIN failure (same content, unpinned, risk = inconsistency); # post-2144 the identical line is a POLICY failure (DIFFERENT, ungated content # shipped as root). And "no tag resolves" is not a random event — it correlates # with tags deleted, release process broken, incident in progress. So we fail # CLOSED, which is a no-op, not a brick: the box keeps the CLI it already has, # exactly as it does every hour nothing is published. Two explicit valves out # already exist and the error names both — GH_SHA (pin a tree directly) and # REPO (override the source entirely). # >>> DIVE-4223 release hold # DIVE-4223 — THE RELEASE HOLD, and why it lives on `main` rather than on the # release object. # # The intuition "flag the bad release prerelease and every box falls back" is a # NO-OP for us: `releases/latest` honours that flag and nothing on a customer box # resolves through it. All three rungs below enumerate a TAG LIST, and a git tag # is not a GitHub Release — the Release object carries the flag, the tag carries # the code, and the tag is what gets installed. Measured 2026-09-09; the table is # in community/wiki/a-brake-is-only-a-brake-if-the-fleets-resolver-reads-it.md. # Deleting or moving a published tag is the other option and is barred: boxes # already on it have no coherent story. # # What IS reachable: `5dive self-update` re-fetches THIS FILE from `main` on every # run, so `main` is a live fleet control plane — one commit to arm a hold, one to # revert, and it reaches every box at its next 04:00Z. So the hold is a file on # `main` that this resolver reads and SKIPS the named tags. # # THE HOLD IS READ FROM `main`, NEVER FROM THE TAG BEING RESOLVED. Reading it # from the candidate tree would let a bad release exempt itself, which is the # whole failure this exists to prevent. # # THREE STATES, and the third is the point. `open` (fetched, tag not listed) and # `held` (fetched, tag listed) are both ANSWERS. "Could not read the hold" is not # an answer and must never collapse into `open` — a broken signal failing open # and a real outage are the same observation, and the direction that greens on # nothing is the one that ships the release the hold exists to stop. So an # unreadable hold FAILS CLOSED, and failing closed here is a no-op rather than a # brick: the box keeps the CLI it already has, exactly as it does every hour # nothing is published. GH_SHA= and REPO= remain the documented ways out. # # The file is COMMITTED and always present with a version banner, so "404" is not # a state we have to interpret: any fetch that does not return a body starting # `# 5dive-release-hold v1` is `unknown`, which also catches a CDN or captive # portal handing back a 200 of HTML. # # WHAT A HOLD DOES NOT DO: it holds boxes BACK, it does not pull them back. A box # that already installed the held tag stays on it (DIVE-2243's monotonicity guard # refuses the downgrade). The brake bounds how many boxes reach a bad release; it # does not undo the ones that did. RELEASE_HOLD_LIST="" # newline-separated held tag names; may legitimately be empty RELEASE_HOLD_STATE="" # ok | unreadable # Populates RELEASE_HOLD_LIST / RELEASE_HOLD_STATE. Never fails the caller. load_release_hold() { local body="" # Idempotent: resolve_cli_target consults the hold once per run, and on the # canary rung resolve_gh_tag has already loaded it. Two fetches would double # the fail-closed surface for no extra information. [[ -z "$RELEASE_HOLD_STATE" ]] || return 0 # Rung 1: the raw CDN — the same origin this installer itself was fetched from, # so its availability is already a precondition of getting this far. body="$(curl -fsSL --max-time 10 \ "https://raw.githubusercontent.com/$GH_ORG/5dive/main/.release-hold" 2>/dev/null)" || body="" # Rung 2: the contents API. Not redundancy for its own sake — fail-closed means # a raw.githubusercontent blip would otherwise stop the whole fleet's nightly # upgrade, so the second rung is what keeps "unknown" rare enough to be signal. if [[ "$body" != "# 5dive-release-hold v1"* ]]; then body="$(curl -fsSL --max-time 10 -H 'Accept: application/vnd.github.raw' \ "https://api.github.com/repos/$GH_ORG/5dive/contents/.release-hold?ref=main" 2>/dev/null)" || body="" fi if [[ "$body" != "# 5dive-release-hold v1"* ]]; then RELEASE_HOLD_LIST=""; RELEASE_HOLD_STATE="unreadable"; return 0 fi # A held line is a bare release tag, optionally followed by a human reason. # Anything else in the file (comments, blank lines, the banner) is not a hold — # a typo must not silently become one. The file's own header states the exact # line shape for whoever arms it under incident pressure. RELEASE_HOLD_LIST="$(printf '%s\n' "$body" \ | sed -n 's/^\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)\([[:space:]].*\)\{0,1\}$/\1/p')" RELEASE_HOLD_STATE="ok" } # <<< DIVE-4223 release hold # Newest release tag name (e.g. v0.15.34) on stdout. # 0 resolved tag on stdout # 1 no tag resolved the release rail is unreachable or empty # 2 hold unreadable we could not establish whether a hold is armed # 3 every candidate held resolve_gh_tag() { local tags="" # git ls-remote is exact and carries no API rate limit. A brand-new box may # not have git yet — this script is what apt-installs it — so this is the # normal path on every re-install and self-update, not on the first one. if command -v git >/dev/null 2>&1; then tags="$(git ls-remote --tags --refs "https://github.com/$GH_ORG/5dive.git" 'v*' 2>/dev/null \ | sed -n 's#.*refs/tags/##p')" || tags="" fi # First-install fallback: the tags atom feed. Unauthenticated, and not subject # to the 60/hr api.github.com limit a NAT'd fleet would share. Parse the # — NOT the , which carries a human release headline after the tag # ("v0.15.34 — task set-body") and matches nothing on the real feed. if [[ -z "$tags" ]]; then tags="$(curl -fsSL --max-time 10 "https://github.com/$GH_ORG/5dive/tags.atom" 2>/dev/null \ | sed -n 's#.*<id>tag:github.com,[0-9]*:Repository/[0-9]*/\([^<]*\)</id>.*#\1#p')" || tags="" fi # Last resort before giving up on resolving a tag altogether. if [[ -z "$tags" ]]; then tags="$(curl -fsSL --max-time 10 "https://api.github.com/repos/$GH_ORG/5dive/tags?per_page=100" 2>/dev/null \ | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\(v[0-9][^"]*\)".*/\1/p')" || tags="" fi [[ -n "$tags" ]] || return 1 # `sort -V`, never `sort` — see the LEXICAL SORT note above. The regex also # drops anything that is not a plain vMAJOR.MINOR.PATCH release tag, so a # `v1.0.0-rc1` or a `nightly` can never become the thing every box installs. local candidates candidates="$(printf '%s\n' "$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)" [[ -n "$candidates" ]] || return 1 # Resolve the hold ONLY once we know a tag exists at all, so an unreachable # release rail keeps reporting itself as an unreachable release rail rather # than being reported as a hold problem. Two different incidents, two pages. load_release_hold [[ "$RELEASE_HOLD_STATE" == "ok" ]] || return 2 # Subtract the held tags, THEN take the newest of what is left — a hold on the # newest tag must land the box on the previous good release, not on nothing: # holding one bad cut must not read to a box as "the release rail is broken". # Whole-line fixed-string match (`-vxF`), so a hold on v0.1.2 can never also # hold v0.1.20. Coreutils only, no `tac` and no process substitution: this runs # on a first-boot box before we have installed anything. if [[ -n "$RELEASE_HOLD_LIST" ]]; then candidates="$(printf '%s\n' "$candidates" \ | grep -vxF "$RELEASE_HOLD_LIST")" || candidates="" fi local newest newest="$(printf '%s\n' "$candidates" | tail -1)" # Distinct from the `return 1` above: the rail is healthy and the refusal is # deliberate. An operator paged for "no tag resolved" looks at GitHub; one # paged for "every tag is held" looks at whoever armed the hold. [[ -n "$newest" ]] || return 3 printf '%s\n' "$newest" } # >>> DIVE-4140 stable CLI target # Customer boxes follow the promoted fleet tag, not the newest release merely # because it exists. Resolution order is deliberately explicit: # local override > canary opt-in > fleet stable route > last known stable. # A failed/invalid route NEVER means "latest". On an existing box the running # version is the floor, so stale control-plane state cannot cause a downgrade; # FIVE_ALLOW_DOWNGRADE=1 remains the deliberate rollback escape hatch. # DIVE-4294: the resolution RECEIPT. resolve_cli_target is the only authority on # which tag this box installs and from which rung; until now that answer existed # only in this function's locals and died with the process, so the dashboard's # "Updates started" could not say what started. stdout here is the RETURN VALUE # (every caller does GH_PINNED_TAG="$(resolve_cli_target)") and stderr is dropped # by the soft-update exec-into-log, so the receipt is a FILE — the one surface a # later `5dive update --check --json` can read back without a new shelld verb. # # `rung` is a stable machine token, deliberately NOT the prose `source` (which is # already the free text a human reads) and deliberately NOT reusing the name # `source` from `update --check --json`, where it means "the ref the answer came # from". A pinned box and a canary box on the SAME tag differ ONLY here. # # Best-effort in the strictest sense: it writes no byte to stdout, fails silent, # and its failure can never fail an install. _record_cli_target_receipt() { local tag="$1" rung="$2" detail="$3" local receipt="${CLI_TARGET_RECEIPT_FILE:-/var/lib/5dive/cli-target.json}" local dir; dir="$(dirname "$receipt")" mkdir -p "$dir" 2>/dev/null || return 0 local tmp="${receipt}.tmp.$$" # Hand-rolled JSON: install.sh runs before anything is installed, so jq is not # a dependency it may take. Every value here is either a validated tag, one of # four literal rungs, or a path we composed — no user text reaches it unescaped # except $detail, whose only variable part is a file path or URL from our own # defaults; backslashes and quotes are stripped from it rather than escaped. local safe_detail; safe_detail="${detail//\\/}"; safe_detail="${safe_detail//\"/}" # 2>/dev/null comes BEFORE the > redirection deliberately: redirections are # applied left to right, so with the order reversed an unwritable state dir # makes BASH itself print "Permission denied" — onto the caller's stderr, # which on the install path is a customer's install log and on the resolver # path sits one pipe away from the return value. Graded by the arm named # "receipt writing is silent when the state dir is unwritable". printf '{"tag":"%s","rung":"%s","rungDetail":"%s","at":"%s"}\n' \ "$tag" "$rung" "$safe_detail" "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" \ 2>/dev/null > "$tmp" || { rm -f "$tmp" 2>/dev/null; return 0; } chmod 0644 "$tmp" 2>/dev/null || true mv -f "$tmp" "$receipt" 2>/dev/null || rm -f "$tmp" 2>/dev/null return 0 } resolve_cli_target() { local override_file="${CLI_VERSION_OVERRIDE_FILE:-/etc/5dive/cli-version}" local canary_file="${CLI_CANARY_FILE:-/etc/5dive/cli-canary}" local known_file="${CLI_VERSION_KNOWN_FILE:-/var/lib/5dive/cli-version.last-known}" local route="${CLI_VERSION_URL:-https://api.5dive.com/cli-version}" local installed_bin="${CLI_INSTALLED_BIN:-/usr/local/bin/5dive}" local target="" source="" floor="" rung="" if [[ -e "$override_file" ]]; then target="$(tr -d '[:space:]' < "$override_file" 2>/dev/null)" source="local override $override_file"; rung=override if [[ ! "$target" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf 'error: 5dive install: invalid CLI tag in %s (expected vMAJOR.MINOR.PATCH).\n' "$override_file" >&2 return 1 fi elif [[ -e "$canary_file" ]]; then # DIVE-4223: resolve_gh_tag already subtracts the hold from its candidate # list, so the canary rung's hold answer arrives as its RETURN CODE. `|| true` # here would flatten "hold unreadable" and "every tag held" into the generic # "no tag resolved" page, which is the one thing the three codes exist to # keep apart. local _rgt_rc=0 target="$(resolve_gh_tag)" || _rgt_rc=$? case "$_rgt_rc" in 2|3) return "$_rgt_rc" ;; esac source="canary opt-in ($canary_file) — newest released tag, ahead of the fleet pin"; rung=canary else target="$(curl -fsSL --max-time 5 "$route" 2>/dev/null | tr -d '[:space:]')" || target="" if [[ "$target" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then source="fleet stable route $route"; rung=route if mkdir -p "$(dirname "$known_file")" 2>/dev/null; then local known_tmp="${known_file}.tmp.$$" if printf '%s\n' "$target" > "$known_tmp" 2>/dev/null; then chmod 0644 "$known_tmp" 2>/dev/null || true mv -f "$known_tmp" "$known_file" 2>/dev/null || rm -f "$known_tmp" fi fi else if [[ -r "$known_file" ]]; then target="$(tr -d '[:space:]' < "$known_file" 2>/dev/null)" else target="" fi source="last known stable $known_file"; rung=last-known fi fi if [[ ! "$target" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf 'error: 5dive install: NO STABLE CLI TAG RESOLVED — route unavailable/invalid and no usable last-known tag.\n' >&2 return 1 fi # DIVE-4223 / DIVE-4366 — THE HOLD IS CHECKED HERE, not only inside # resolve_gh_tag, because since DIVE-4140 resolve_gh_tag is NOT the fleet's # resolver: it answers the CANARY rung only. A customer box takes the `route` # rung (the fleet pin from api.5dive.com) or `last-known`, and neither passes # through a candidate list there is anything to subtract from. A hold wired # only into resolve_gh_tag would therefore brake the canary boxes and leave # every box the brake exists for running — a brake is only a brake if the # resolver the fleet actually uses reads it. # # Single tags have no "next best candidate", so a held target is a REFUSAL, # not a fallback: falling back to last-known would turn a hold into a silent # downgrade, and the hold holds boxes BACK rather than pulling them back. The # box keeps the CLI it has; a fresh install stops with nothing written. On the # canary rung this is a no-op (the tag returned was already unheld) and costs # no second fetch — load_release_hold is idempotent. # # The `override` rung is included deliberately. A local /etc/5dive/cli-version # is an operator choice, but so is arming a hold against a release known to be # broken, and the operator's way past it is the same documented pair the other # refusals name: GH_SHA= or REPO=, both of which skip this resolver entirely. load_release_hold if [[ "$RELEASE_HOLD_STATE" != "ok" ]]; then printf 'error: 5dive install: release hold on main is UNREADABLE — cannot confirm %s is not held.\n' "$target" >&2 return 2 fi if [[ -n "$RELEASE_HOLD_LIST" ]] && printf '%s\n' "$RELEASE_HOLD_LIST" | grep -qxF "$target"; then printf 'error: 5dive install: CLI target %s (%s) is HELD on main — staying put.\n' "$target" "$source" >&2 return 3 fi if [[ -x "$installed_bin" ]]; then floor="$($installed_bin --version 2>/dev/null | awk 'NR==1 {print $2}')" || floor="" fi if [[ "${FIVE_ALLOW_DOWNGRADE:-0}" != 1 && "$floor" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then local candidate="${target#v}" if [[ "$candidate" != "$floor" && "$(printf '%s\n%s\n' "$candidate" "$floor" | sort -V | head -1)" == "$candidate" ]]; then printf 'error: 5dive install: stable target %s from %s is below installed floor %s — staying put.\n' "$target" "$source" "$floor" >&2 return 1 fi fi # DIVE-4274. Say WHICH RUNG answered, not just what it answered. # # "Updates started", then the same version as before, with no reason given, is # what produced "why it skipped update?" — the box was following the fleet pin # and behaving exactly as designed, and nothing said so. The four rungs are # indistinguishable from their result: a box on the pin and a box on the # canary that happen to agree print the same tag. # # STDERR, not stdout: every caller captures this function with $(...) and the # tag is the return value. A human-facing line on stdout would be appended to # the tag and installed as a version string. It still reaches the operator — # the nightly driver and the dashboard's update both keep stderr in the log. printf '5dive install: CLI target %s — source: %s\n' "$target" "$source" >&2 _record_cli_target_receipt "$target" "$rung" "$source" printf '%s\n' "$target" } # <<< DIVE-4140 stable CLI target # Commit sha for tag $1 on stdout, or return 1. Same three-rung ladder. resolve_gh_sha() { local tag="$1" sha="" out="" if command -v git >/dev/null 2>&1; then # An ANNOTATED tag's own sha is the tag object, which raw.githubusercontent # does not serve — the `^{}` peel is the commit. 97 of our tags are # annotated, so preferring the peeled line is required, not defensive. out="$(git ls-remote --tags "https://github.com/$GH_ORG/5dive.git" "refs/tags/$tag" "refs/tags/$tag^{}" 2>/dev/null)" || out="" sha="$(printf '%s\n' "$out" | awk '$2 ~ /\^\{\}$/ {print $1; exit}')" [[ -n "$sha" ]] || sha="$(printf '%s\n' "$out" | awk 'NR==1 {print $1}')" if [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then printf '%s\n' "$sha"; return 0; fi fi sha="$(curl -fsSL --max-time 10 "https://github.com/$GH_ORG/5dive/commits/$tag.atom" 2>/dev/null \ | sed -n 's#.*Grit::Commit/\([0-9a-f]\{40\}\).*#\1#p' | head -1)" || sha="" if [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then printf '%s\n' "$sha"; return 0; fi sha="$(curl -fsSL --max-time 10 "https://api.github.com/repos/$GH_ORG/5dive/commits/$tag" 2>/dev/null \ | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{40\}\)".*/\1/p' | head -1)" || sha="" if [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then printf '%s\n' "$sha"; return 0; fi return 1 } # Source for binaries / hooks / skills. Overridable for offline installs, # enterprise mirrors, and pre-publish smoke tests (which point this at a # `file://` bundle of the working tree) — an explicit REPO is never re-pinned, # because we can't vouch for a foreign mirror's internal consistency. GH_SHA # pins the tree directly, skipping resolution (CI wanting a PR head, rollbacks). # Both are the documented ways OUT of the tag rail, and the fail-closed errors # below name them, so an operator leaves the guarantee by choosing to rather # than by being moved out of it silently. GH_PINNED_SHA="${GH_SHA:-}" GH_PINNED_TAG="" if [[ "${1:-}" == "--uninstall" ]]; then # Uninstall downloads nothing, so it must never be gated on the release rail # being healthy. Failing closed here would be a brick in the one direction # fail-closed exists to prevent: "we cannot publish right now" must not become # "you cannot remove what we already installed". REPO="${REPO:-}" GH_PINNED_SHA="" elif [[ -z "${REPO:-}" ]]; then if [[ -z "$GH_PINNED_SHA" ]]; then GH_PINNED_TAG=""; _rct_rc=0 GH_PINNED_TAG="$(resolve_cli_target)" || _rct_rc=$? if [[ -z "$GH_PINNED_TAG" ]]; then # Distinct and greppable on purpose: this must never read like the ordinary # "pinned to <tag>" line, and must never be a silent `|| true` into main. # DIVE-4223: and the three refusals must stay DISTINGUISHABLE in the log — # "we could not read the hold" is a different incident from "the release # rail is empty", and one operator page that covers both tells you nothing. case "$_rct_rc" in 2) printf 'error: 5dive install: RELEASE HOLD UNREADABLE — refusing to install without knowing whether a release is held.\n' >&2 printf ' This is NOT "no hold is armed": we could not fetch .release-hold from main at all.\n' >&2 ;; 3) printf 'error: 5dive install: RELEASE TARGET IS HELD — refusing to install a held release.\n' >&2 printf ' A hold is armed on main against the tag this box resolves. This is deliberate; ask whoever armed it.\n' >&2 ;; *) printf 'error: 5dive install: NO STABLE RELEASE TAG RESOLVED — refusing to install from ungated main.\n' >&2 ;; esac printf ' Nothing was changed; if 5dive is already installed it keeps running the version it has.\n' >&2 printf ' Retry later, or choose a source explicitly:\n' >&2 printf ' GH_SHA=<40-hex commit> pin one tree directly (rollback, CI, a PR head)\n' >&2 printf ' REPO=<base url> install from a mirror or an offline bundle\n' >&2 exit 1 fi GH_PINNED_SHA="$(resolve_gh_sha "$GH_PINNED_TAG" || true)" if [[ -z "$GH_PINNED_SHA" ]]; then printf 'error: 5dive install: RELEASE TAG %s RESOLVED BUT ITS COMMIT DID NOT — refusing to install from ungated main.\n' "$GH_PINNED_TAG" >&2 printf ' Nothing was changed. Retry later, or set GH_SHA=<40-hex commit> / REPO=<base url> explicitly.\n' >&2 exit 1 fi fi REPO="https://raw.githubusercontent.com/$GH_ORG/5dive/$GH_PINNED_SHA" else GH_PINNED_SHA="" fi # <<< DIVE-1977 pin-resolution block BIN_DIR="/usr/local/bin" STATE_DIR="/var/lib/5dive" CONNECTORS_DIR="/etc/5dive/connectors" SYSTEMD_DIR="/etc/systemd/system" LIB_DIR="/usr/local/lib/5dive" NODE_VERSION="22" die() { echo "error: $*" >&2; exit 1; } ok() { echo " ✓ $*"; } # >>> DIVE-4350 optional-at-pin download contract # ONE helper for every download that main's install.sh may name BEFORE the fleet # pin's tree carries it. install.sh is fetched live from `main`, but every # `$REPO/…` below resolves against the PINNED tag (DIVE-4140), so main naming a # file first shipped in a later tag is a 404 — and under `set -e` that 404 was # the whole install (DIVE-4349). # # THE CONTRACT, and it is the entire point of the helper: # # HTTP 404 -> the pinned tree genuinely does not ship this file yet. # Skip it, named on stderr, never silent. # ANYTHING ELSE (DNS failure, connection refused, timeout, TLS, 5xx, a # proxy's 403, a partial body) -> FAIL CLOSED. # # Why that second line is not defensive padding: the DIVE-4349 hotfix used # `curl -fsSL … 2>/dev/null` and `-f` collapses every 4xx/5xx into exit 22, so # the `else` arm labelled a transient blip "not shipped at this pin" (quinn, on # #908 — proved with a connection-refused fetch under GH_PINNED_TAG=v0.35.1: # exit 0, hook absent, false cause on stderr). Once the fleet pin passes the tag # that ships a hook, that blip silently un-wires it on that box and lies about # why. `-f` is therefore DELIBERATELY ABSENT here: we need the status code back. # # Usage: fetch_optional_at_pin <repo-relative path> <dest> # returns 0 installed at $2 # returns 1 legitimately absent at this pin (caller decides what to unwire) # exits anything else — a failure that is not a 404 is not a policy # decision this function is allowed to make quietly. fetch_optional_at_pin() { local _path="$1" _dest="$2" _tmp _code _rc _tmp="$(mktemp "${_dest}.XXXXXX")" || die "failed to create a temp file beside $_dest" # An explicit file:// source (install-smoke's offline bundle) carries no HTTP # status at all — curl reports 000 for a SUCCESSFUL read — so absence there is # curl's own exit 37, not a code. Kept separate rather than folded in, because # folding it in is what makes 000 ambiguous again. if [[ "$REPO" == file://* ]]; then if curl -sSL -o "$_tmp" "$REPO/$_path" 2>/dev/null; then mv -f "$_tmp" "$_dest"; return 0 fi rm -f "$_tmp" echo " ! $_path is not in this local bundle ($REPO) — skipped" >&2 return 1 fi if _code="$(curl -sSL --max-time 30 -w '%{http_code}' -o "$_tmp" "$REPO/$_path" 2>/dev/null)"; then _rc=0 else _rc=$? fi if [[ $_rc -eq 0 && "$_code" == "200" ]]; then mv -f "$_tmp" "$_dest" return 0 fi rm -f "$_tmp" if [[ $_rc -eq 0 && "$_code" == "404" ]]; then echo " ! $_path not shipped at this pin (${GH_PINNED_TAG:-${GH_PINNED_SHA:-unpinned}}) — skipped; the bundle at this pin does not wire it" >&2 return 1 fi die "failed to download $_path from $REPO/$_path (http ${_code:-000}, curl rc $_rc) — this is NOT a 404, so it is not 'absent at this pin'; refusing to silently drop a file the pinned tree may well ship (DIVE-4350)" } # <<< DIVE-4350 optional-at-pin download contract # Replace an executable by RENAME, never in place. `curl -o <path>` opens the # existing file with O_TRUNC and keeps its inode, and bash reads a running script # by byte offset as it goes. So a `bash <path>` that is still running when this # installer rewrites the file (every seat's main process is # `bash /usr/local/bin/5dive-agent-start <seat>`) resumes at its OLD offset in the # NEW bytes. Measured on a 0.50.0 -> 0.53.0 update: one seat, stopped fourteen # minutes after the write, died with `line 2065: $'\200\224': command not found` # (the tail of a UTF-8 em-dash) and status 127. # # The temp sits beside <dest>, so the `mv` is a same-filesystem rename: <dest> # gets a new inode and a running bash keeps reading the old one to its end. # The fetch command stays on the CALLER's line (the helper appends `-o <temp>`) # so scripts/install-pin-compat.sh still sees, and grades, `curl … "$REPO/<path>"`. # # Usage: replace_by_rename <dest> <fetch command…> replace_by_rename() { local _dest="$1" _tmp; shift _tmp="$(mktemp "${_dest}.XXXXXX")" || die "failed to create a temp file beside $_dest" if ! "$@" -o "$_tmp"; then rm -f "$_tmp" die "failed to download ${_dest##*/} ($*)" fi chmod 755 "$_tmp" mv -f "$_tmp" "$_dest" } say() { echo "→ $*"; } [[ $EUID -eq 0 ]] || die "run as root: curl -fsSL ... | sudo bash" # >>> DIVE-2243 monotonicity guard (extracted verbatim by tests/install_monotonicity_unit.sh) # DIVE-2243: the DIVE-2144 cutover moved publishing from mutable `main` HEAD to # the newest release TAG. For ~23h the newest tag (v0.16.32) sat BELOW what the # fleet was running (0.16.33, .34, .36, then 0.17.0) because release-cut.yml had # never once succeeded (DIVE-2238). Every box that self-updated inside that # window rolled BACKWARDS — and printed `5dive upgraded: 0.16.33 -> 0.16.32` # while doing it. # # Nothing here noticed, because nothing here could: the only version comparison # on the upgrade path was `!=`, and its only job was choosing which of two report # strings to print. `sort -V` appears above to pick the newest TAG, and never # once to compare that tag's payload against what is already installed. So the # old version's sole purpose was a printed string, and that string asserted the # one thing the code never checked. # # A cutover that STALLS is loud — nothing updates, someone notices staleness. A # cutover that REVERSES presents as success: an update ran, it exited 0, and it # said "upgraded". Every signal a monitor watches says the system worked. It was # caught only because a human happened to read the same version number twice. # # So make DIRECTION a first-class assertion, separate from the action: # # - Refuse a strictly-lower candidate by default, naming both versions and the # tag/sha it came from. Same fail-closed posture the tag resolver above takes # for "no tag resolves", and equally a no-op rather than a brick: the box # keeps the CLI it already has, exactly as it does every hour nothing is # published. # - A real rollback is a legitimate operation, so it stays possible — but it # must be ASKED FOR (FIVE_ALLOW_DOWNGRADE=1), never a side effect of tag # resolution. # - Refuse only on versions we can actually ORDER. An unreadable installed or # candidate version is not evidence of a backwards move, and bricking an # upgrade over a grep that came back empty would be a worse failure than the # one this guards. Those cases WARN, name which side was unreadable, and # proceed. # version_lt A B — true when semver A sorts strictly below B. Equal → false. # `sort -V`, never `sort`: lexically "0.16.10" sorts BELOW "0.16.9", so a plain # sort here would refuse ordinary forward upgrades roughly one patch in ten. version_lt() { [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]] } # Only plain release versions carry ordering meaning. In particular, # 0.0.0-dev is a tag-time sentinel, not a version below every release. release_version() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } bundle_build_sha() { local _sha="" _sha="$(grep -m1 'readonly FIVE_BUILD_SHA=' "$1" 2>/dev/null | sed -E 's/.*="([^"]+)".*/\1/')" || _sha="" [[ "$_sha" =~ ^[0-9a-f]{40}$ ]] || return 1 printf '%s\n' "$_sha" } # Legacy release bundles predate FIVE_BUILD_SHA. Their tag points at a detached # release commit whose first parent is the main commit the bundle was built # from, so recover that parent rather than comparing against the detached child. legacy_release_build_sha() { local _version="$1" _json="" _line="" _sha="" release_version "$_version" || return 1 _json="$(curl -fsSL --max-time 10 \ "https://api.github.com/repos/$GH_ORG/5dive/commits/v${_version}" 2>/dev/null)" || return 1 _line="$(printf '%s\n' "$_json" | awk '/"parents"[[:space:]]*:/ { parents=1; next } parents && /"sha"[[:space:]]*:/ { print; exit }')" _sha="$(printf '%s\n' "$_line" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{40\}\)".*/\1/p')" [[ "$_sha" =~ ^[0-9a-f]{40}$ ]] || return 1 printf '%s\n' "$_sha" } # Prints ahead|behind|identical|diverged for installed...candidate. build_relation() { local _installed="$1" _candidate="$2" _json="" _status="" _json="$(curl -fsSL --max-time 10 \ "https://api.github.com/repos/$GH_ORG/5dive/compare/${_installed}...${_candidate}" 2>/dev/null)" || return 1 _status="$(printf '%s\n' "$_json" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" case "$_status" in ahead|behind|identical|diverged) printf '%s\n' "$_status" ;; *) return 1 ;; esac } # Read by the --upgrade report after refresh_managed_files swaps the bundle. INSTALL_DIRECTION="" INSTALL_INSTALLED_SHA="" INSTALL_CANDIDATE_SHA="" # assert_version_monotonic <installed-bin> <candidate-bundle> # 0 = proceed, 1 = refuse (message already on stderr; caller cleans up + exits). # Deliberately does NOT call die(): the caller holds a temp bundle in $BIN_DIR # that must be removed before we exit, and a guard that leaves debris in the # directory it just refused to touch is its own small mess. assert_version_monotonic() { local _inst_bin="$1" _cand_file="$2" _inst="" _cand="" _src="" local _inst_sha="" _cand_sha="" _relation="" _identity_note="" INSTALL_DIRECTION="" INSTALL_INSTALLED_SHA="" INSTALL_CANDIDATE_SHA="" # Fresh install: nothing to move backwards from. if [[ ! -f "$_inst_bin" ]]; then INSTALL_DIRECTION="fresh"; return 0; fi _inst="$(grep -m1 'readonly FIVE_VERSION=' "$_inst_bin" 2>/dev/null | sed -E 's/.*="([^"]+)".*/\1/')" || _inst="" _cand="$(grep -m1 'readonly FIVE_VERSION=' "$_cand_file" 2>/dev/null | sed -E 's/.*="([^"]+)".*/\1/')" || _cand="" _inst_sha="$(bundle_build_sha "$_inst_bin" || true)" _cand_sha="$(bundle_build_sha "$_cand_file" || true)" if [[ -z "$_inst_sha" && -n "$_cand_sha" && -n "$_inst" ]]; then _inst_sha="$(legacy_release_build_sha "$_inst" || true)" [[ -z "$_inst_sha" ]] || _identity_note=" (installed v${_inst} mapped to its release parent)" fi INSTALL_INSTALLED_SHA="$_inst_sha" INSTALL_CANDIDATE_SHA="$_cand_sha" if [[ -n "$_inst_sha" && -n "$_cand_sha" ]]; then _relation="$(build_relation "$_inst_sha" "$_cand_sha" || true)" case "$_relation" in ahead) INSTALL_DIRECTION="forward"; return 0 ;; identical) INSTALL_DIRECTION="same"; return 0 ;; behind) INSTALL_DIRECTION="rollback" if [[ "${FIVE_ALLOW_DOWNGRADE:-0}" == "1" ]]; then echo " ! ROLLBACK build ${_inst_sha} -> ${_cand_sha}${_identity_note} — allowed by FIVE_ALLOW_DOWNGRADE=1" >&2 return 0 fi printf 'error: refusing to install older 5dive build: candidate %s is an ancestor of installed %s%s.\n' \ "$_cand_sha" "$_inst_sha" "$_identity_note" >&2 printf ' Nothing was changed. If this rollback is deliberate, re-run with FIVE_ALLOW_DOWNGRADE=1.\n' >&2 return 1 ;; diverged) echo " ! build identity diverged (installed=${_inst_sha}, candidate=${_cand_sha}) — falling back to release-version ordering when available" >&2 ;; *) echo " ! build ancestry unavailable (installed=${_inst_sha}, candidate=${_cand_sha}) — falling back to release-version ordering when available" >&2 ;; esac fi # Version ordering is still authoritative when both artifacts carry real # release versions. A sentinel or unreadable value is not rollback evidence. if ! release_version "$_inst" || ! release_version "$_cand"; then INSTALL_DIRECTION="unchecked" echo " ! release versions not comparable (installed='${_inst:-unreadable}', candidate='${_cand:-unreadable}') — direction unchecked, proceeding" >&2 return 0 fi if ! version_lt "$_cand" "$_inst"; then if version_lt "$_inst" "$_cand"; then INSTALL_DIRECTION="forward"; else INSTALL_DIRECTION="same"; fi return 0 fi INSTALL_DIRECTION="rollback" # Name where the lower version came from — the whole point is that a backwards # move is traceable to the thing that resolved it. _src="${GH_PINNED_TAG:-}" [[ -n "$_src" ]] || _src="${GH_PINNED_SHA:-}" [[ -n "$_src" ]] || _src="${REPO:-}" if [[ "${FIVE_ALLOW_DOWNGRADE:-0}" == "1" ]]; then echo " ! DOWNGRADE ${_inst} -> ${_cand}${_src:+ (from ${_src})} — allowed by FIVE_ALLOW_DOWNGRADE=1" >&2 return 0 fi printf 'error: refusing to DOWNGRADE 5dive: installed %s, candidate %s%s — this would move the box BACKWARDS.\n' \ "$_inst" "$_cand" "${_src:+, resolved from ${_src}}" >&2 printf ' Nothing was changed; the box keeps %s. If the rollback is deliberate, re-run with FIVE_ALLOW_DOWNGRADE=1.\n' \ "$_inst" >&2 return 1 } # <<< DIVE-2243 monotonicity guard # >>> DIVE-3554 buzz binary staging (extracted verbatim by tests/install_buzz_binaries_unit.sh) # The Connect Buzz panel (DIVE-3551) runs `5dive agent buzz pair`, which shells # out to `buzz-pair`; joining a channel shells out to `buzz`. Until this block # NOTHING on any box installed either one — the cli-v0.1.x releases on # <org>/buzz attach them, but no install/provision/update path downloaded them, # so the shipped panel dead-ended at the verb's "no buzz binary" refusal on every # box except the one where a human hand-placed the asset. This is that path. # # WHY HERE. Enumerated the writers of /usr/local/bin/buzz before adding one: # there were none. `git grep buzz` on 5dive-api's main is EMPTY (provisioning # never mentions it) and this repo's only pre-existing hits were the *claude # plugin* named "buzz" and the resolver in cmd_agent_buzz.sh that LOOKS for the # binary. refresh_managed_files() is the one layer that already owns # /usr/local/bin on a customer box and is reached by all three paths that must # be covered: the default install (fresh provision), `--upgrade`, and the # customer nightly (5dive-api/scripts/update.sh re-curls install.5dive.com, # which lands right back here). Adding a second writer elsewhere would have made # two. # # VERSION-PINNED TO A TAG, NOT "latest". A moving `latest` would hand every box # a different binary on any night the buzz repo publishes, with no review and no # way to say which build a box is running. Bump BUZZ_CLI_TAG and # BUZZ_SUMS_SHA256 together, in one commit, as a deliberate act. # # TWO-LEVEL INTEGRITY, and the second level is the load-bearing one. The release # ships SHA256SUMS next to the binaries, so verifying a binary against it catches # a corrupt download or a mangling mirror. It does NOT catch a swapped release # asset: a GitHub release asset is MUTABLE even on an immutable tag — someone # with write access can delete `buzz` and upload different bytes under the same # tag, and re-upload a matching SHA256SUMS with it, and every box would verify # green against the attacker's own manifest. So the manifest itself is pinned to # a constant in this file, which a release-side swap cannot reach. Verifying # against a manifest you downloaded from the same place as the payload is not a # check; it is a spell. BUZZ_CLI_TAG="${BUZZ_CLI_TAG:-cli-v0.1.2}" BUZZ_SUMS_SHA256="${BUZZ_SUMS_SHA256:-124425c0961df092622cd0225e90b453b885cbe83758e829d0399b02b5f02155}" # Install/refresh `buzz` and `buzz-pair` into $BIN_DIR from the pinned release. # # FAILS SOFT BY DESIGN, and the asymmetry is deliberate: it never installs bytes # it could not verify (a mismatch refuses and leaves the existing binary exactly # where it was), but it never aborts the run either. This function is on the # nightly path of every customer box; making the 5dive CLI update depend on # github.com/<org>/buzz/releases being reachable would let an outage in the relay # release brick every box's CLI update — strictly worse than a box that keeps the # buzz it has and says so. Returns 0 on every path; the caller must not gate on it. stage_buzz_binaries() { local base tmp sums rc=0 arch arch="$(uname -m 2>/dev/null || echo unknown)" if [[ "$arch" != "x86_64" ]]; then # The release attaches ONE build per binary and PROVENANCE.txt records it as # `ELF 64-bit ... x86-64`. There is no arch matrix to select from yet, so on # anything else we say so rather than installing a binary that cannot exec. echo "warn: buzz binaries not staged — the $BUZZ_CLI_TAG release ships x86_64 builds only and this box is $arch. \`5dive agent buzz pair\` will keep refusing until a build for this architecture is published." >&2 return 0 fi # BUZZ_REL_BASE is the seam the unit harness points at a file:// fixture, and # the same valve an offline/mirrored install uses. Default is the pinned tag. base="${BUZZ_REL_BASE:-https://github.com/$GH_ORG/buzz/releases/download/$BUZZ_CLI_TAG}" tmp="$(mktemp -d)" || return 0 if ! curl -fsSL "$base/SHA256SUMS" -o "$tmp/SHA256SUMS" 2>/dev/null; then echo "warn: could not fetch $base/SHA256SUMS — buzz binaries not refreshed this run; this box keeps whatever it already has (\`5dive agent buzz pair\` refuses outright if that is nothing)." >&2 rm -rf "$tmp"; return 0 fi sums="$(sha256sum "$tmp/SHA256SUMS" | awk '{print $1}')" if [[ "$sums" != "$BUZZ_SUMS_SHA256" ]]; then # Do not soften this into "stale mirror". The manifest is fetched from an # immutable TAG url, so the bytes behind it changed under a name that was # supposed to be fixed. Refuse and touch nothing. echo "warn: buzz SHA256SUMS for $BUZZ_CLI_TAG does not match the digest pinned in install.sh (want ${BUZZ_SUMS_SHA256:0:16}…, got ${sums:0:16}…) — refusing to install unverified relay binaries. Nothing was changed. The release assets behind this tag were replaced, or the source is not the release." >&2 rm -rf "$tmp"; return 0 fi local bin want got dst bin_tmp for bin in buzz buzz-pair; do dst="$BIN_DIR/$bin" want="$(awk -v b="$bin" '$2 == b || $2 == "*" b {print $1; exit}' "$tmp/SHA256SUMS")" if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then echo "warn: $BUZZ_CLI_TAG SHA256SUMS names no sha256 for '$bin' — not installed. \`5dive agent buzz pair\` needs buzz-pair; \`5dive agent buzz join\` needs buzz." >&2 rc=1; continue fi # Idempotence is what makes this cheap on the nightly: an unchanged box does # one 147-byte manifest fetch and zero binary downloads. It also means a box # a human hand-placed the RIGHT build on is a no-op, not a re-download. if [[ -x "$dst" ]] && [[ "$(sha256sum "$dst" | awk '{print $1}')" == "$want" ]]; then ok "$bin already at $BUZZ_CLI_TAG (sha256 verified)" continue fi # Temp lives in BIN_DIR so the final mv is a same-fs atomic swap — a box that # loses power mid-update has either the old binary or the new one, never a # half-written file that execs into garbage. Same shape as the bundle swap. bin_tmp="$(mktemp "${BIN_DIR}/.${bin}.XXXXXX")" || { rc=1; continue; } if ! curl -fsSL "$base/$bin" -o "$bin_tmp" 2>/dev/null; then rm -f "$bin_tmp" echo "warn: failed to download $bin from $base — not refreshed; this box keeps the copy it has." >&2 rc=1; continue fi got="$(sha256sum "$bin_tmp" | awk '{print $1}')" if [[ "$want" != "$got" ]]; then rm -f "$bin_tmp" echo "warn: $bin checksum mismatch (want ${want:0:16}…, got ${got:0:16}…) — refusing to install. The existing $dst was left untouched." >&2 rc=1; continue fi chmod 755 "$bin_tmp" mv -f "$bin_tmp" "$dst" ok "$bin → $dst ($BUZZ_CLI_TAG, sha256 verified)" done rm -rf "$tmp" [[ $rc -eq 0 ]] || echo "warn: buzz staging finished with at least one binary not installed — the dashboard's Connect Buzz panel will dead-end on this box until it is." >&2 return 0 } # <<< DIVE-3554 buzz binary staging # Refresh CLI binaries, systemd unit, hooks, and skills from $REPO. Shared by # the default install path and `--upgrade`. Never touches state, auth profiles, # the claude user, apt packages, nvm, or bun — so it's safe to rerun on a # populated host. refresh_managed_files() { # DIVE-1261: fetch the bundle to a temp file, verify it against the published # sha256, then atomically swap it in. A checksum MISMATCH or an absent network # checksum is fatal. The one deliberate exception is an explicit file:// source: # install-smoke owns those local bytes and intentionally carries no checksum. # (Integrity check v1 guards corruption + mirror tamper; not signing-strength — # that needs an out-of-band key.) Temp lives in BIN_DIR so the final mv is a # same-fs atomic swap. local _bundle_tmp; _bundle_tmp="$(mktemp "${BIN_DIR}/.5dive.XXXXXX")" curl -fsSL "$REPO/5dive" -o "$_bundle_tmp" || { rm -f "$_bundle_tmp"; die "failed to download 5dive bundle from $REPO/5dive"; } local _want _got # `|| _want=""` is load-bearing: under `set -euo pipefail` (line 6) a plain # assignment whose command-substitution pipeline fails aborts the whole script # BEFORE the policy below can classify an absent checksum. The # offline install-smoke bundle (REPO=file:///opt/5dive-bundle) ships no # 5dive.sha256, so curl exits 37 (CURLE_FILE_COULDNT_READ_FILE) and pipefail # propagates it — reddening docker-install at the "Installing CLI binaries" # step (DIVE-1271). Swallowing it here lets the policy distinguish that explicit # local source from a network source whose integrity object disappeared. # >>> DIVE-2248 checksum policy _want="$(curl -fsSL "$REPO/5dive.sha256" 2>/dev/null | tr -d '[:space:]')" || _want="" if [[ -n "$_want" ]]; then _got="$(sha256sum "$_bundle_tmp" | awk '{print $1}')" if [[ "$_want" != "$_got" ]]; then rm -f "$_bundle_tmp" # DIVE-1977: name the cause we can actually justify. Pinned to one commit # sha, both objects came from the same immutable tree, so a mismatch really # is bad bytes. Unpinned (sha resolution failed), the far likelier cause is # two CDN cache generations of a mutable ref — do NOT accuse the operator's # mirror of tampering for that. if [[ -n "$GH_PINNED_SHA" ]]; then die "5dive bundle checksum mismatch (want ${_want:0:16}…, got ${_got:0:16}…) — refusing to install. Both objects came from the immutable tree $GH_PINNED_SHA, so this is not a cache skew: the download is corrupt or the mirror is tampered." fi die "5dive bundle checksum mismatch (want ${_want:0:16}…, got ${_got:0:16}…) — refusing to install. This box could not pin a commit sha, so the bundle and its checksum were fetched from the mutable ref $REPO and may be two different CDN cache generations (a stale mirror in the minutes after a release) rather than a corrupt download or a tampered mirror. Retry in a few minutes; if it persists, treat it as an integrity failure." fi elif [[ "$REPO" == file://* ]]; then echo " ! local file:// source has no 5dive.sha256 — proceeding without a network integrity check" >&2 else rm -f "$_bundle_tmp" die "failed to fetch required 5dive.sha256 from $REPO/5dive.sha256 — refusing to install an unverified bundle" fi # <<< DIVE-2248 checksum policy # DIVE-2243: direction is a separate assertion from the action. Checked HERE — # after integrity, before the swap — so a refusal leaves the installed binary # untouched and the box keeps running the version it already has. if ! assert_version_monotonic "$BIN_DIR/5dive" "$_bundle_tmp"; then rm -f "$_bundle_tmp" exit 1 fi chmod 755 "$_bundle_tmp" mv -f "$_bundle_tmp" "$BIN_DIR/5dive" ok "5dive → $BIN_DIR/5dive${_want:+ (sha256 verified)}" # DIVE-4100: `agent list` crosses privilege once through this tiny extractor # instead of re-entering the 95k-line shell bundle or sudo-reading every # agent file separately. The helper accepts no arguments and its Python body # comes from the checksummed bundle just installed above. install -d -m 755 "$LIB_DIR" local _list_helper_tmp _list_helper_tmp="$(mktemp "${LIB_DIR}/.agent-list-snapshot.XXXXXX")" curl -fsSL "$REPO/5dive-agent-list-snapshot" -o "$_list_helper_tmp" \ || { rm -f "$_list_helper_tmp"; die "failed to download agent-list snapshot helper"; } chmod 755 "$_list_helper_tmp" mv -f "$_list_helper_tmp" "$LIB_DIR/agent-list-snapshot" ok "agent-list snapshot helper → $LIB_DIR/agent-list-snapshot" # Admin and standard seats share the claude group; sandboxed seats do not. # Grant only this argument-free, verdict-only reader. A separate managed file # backfills existing seats immediately instead of waiting for reprovision. local _list_sudo_tmp _list_sudo_tmp="$(mktemp /etc/sudoers.d/.5dive-agent-list.XXXXXX)" printf '%%claude ALL=(root) NOPASSWD: %s/agent-list-snapshot\n' "$LIB_DIR" >"$_list_sudo_tmp" chmod 440 "$_list_sudo_tmp" if ! visudo -cf "$_list_sudo_tmp" >/dev/null 2>&1; then rm -f "$_list_sudo_tmp" die "agent-list snapshot sudoers policy failed validation" fi mv -f "$_list_sudo_tmp" /etc/sudoers.d/5dive-agent-list ok "/etc/sudoers.d/5dive-agent-list (one read-only fleet snapshot)" # DIVE-4081: the sudoers template is installed runtime, not just agent-create # state. Existing standard seats otherwise keep the grant set they were born # with, so a newly shipped narrow root primitive exists but is unreachable. # The new bundle touches only clean 5dive-managed cli-scoped files and # preserves conditional push/deploy grants from the enforced file. if ! "$BIN_DIR/5dive" agent _reconcile_sudoers; then echo "warn: existing standard-seat sudoers were not reconciled; routed reviewers may be unable to use newly shipped narrow primitives" >&2 fi # DIVE-5690: the box's keys (connectors/*, connectord.env) leave group claude, # which every standard seat is in, for claude-keys (claude + admin seats). # Before the agent restart below, so an admin seat comes back up holding the # new group. install.sh deploys on merge, ahead of the release carrying the # verb, so a bundle that predates it is skipped rather than warned about. if grep -q 'cmd_secrets_posture' "$BIN_DIR/5dive" 2>/dev/null \ && ! "$BIN_DIR/5dive" _secrets_posture; then echo "warn: this box's key files were not moved off group claude; every standard seat can still read them until 'sudo 5dive _secrets_posture' succeeds" >&2 fi # DIVE-3966: new Codex seats get the managed AGENTS.md baseline at create # time; this pass backfills and updates existing seats on every upgrade. if ! "$BIN_DIR/5dive" agent _sync_codex_baseline; then echo "warn: existing Codex AGENTS.md baselines were not fully reconciled; user-authored text was left untouched" >&2 fi # DIVE-4863: Claude Code >= 2.1.280 ignores the top-level effortLevel for # claude-opus-5-5 and newer, so every existing seat ran at medium. Add the # per-model key it does read; self-update's settings.json fingerprint then # restarts idle seats (busy ones at their next task boundary). This file # deploys on merge, ahead of the release carrying the verb, so a bundle that # predates it is skipped rather than warned about; a failed heal warns and # never fails the upgrade. if grep -q 'cmd_agent_heal_effort' "$BIN_DIR/5dive" 2>/dev/null \ && ! "$BIN_DIR/5dive" agent _heal_effort >/dev/null 2>&1; then echo "warn: per-model effort was not healed on every claude seat; those seats run Opus 5.5 at medium until 'sudo 5dive agent _heal_effort' succeeds" >&2 fi # DIVE-4667: mint stable ids for old seats and install the hook immediately; # no agent restart is needed because git reads hooksPath on every commit. if ! "$BIN_DIR/5dive" agent _reconcile_coauthors; then echo "warn: existing agent co-author hooks were not fully reconciled" >&2 fi # DIVE-3554: the relay binaries the shipped Connect Buzz panel shells out to. # Fail-soft on purpose (see stage_buzz_binaries) — a buzz release outage must # not stop the CLI update this function exists to perform. stage_buzz_binaries # DIVE-544: per-customer standup digest. The cron runs HOURLY but `digest tick` # is gated on a per-box pref that defaults OFF — nothing is delivered until a # customer runs `/digest on` (Mark: opt-in only). The cron is idempotent # (rewritten every update); the PREF is seeded once and never clobbered, so an # off/on choice + custom hour survives CLI updates. if [[ -d /etc/cron.d ]]; then cat > /etc/cron.d/5dive-digest <<'DIGESTCRON' # 5dive per-customer standup digest (DIVE-544) — hourly driver; gated on the # per-box pref (default OFF, set via the telegram /digest command). 0 * * * * root /usr/local/bin/5dive digest tick >> /var/log/5dive-digest.log 2>&1 DIGESTCRON chmod 644 /etc/cron.d/5dive-digest ok "/etc/cron.d/5dive-digest (standup digest driver)" fi # Seed the pref OFF on first install only — never overwrite a customer's choice. if [[ -n "${STATE_DIR:-}" && ! -f "${STATE_DIR}/digest.json" ]]; then mkdir -p "${STATE_DIR}" echo '{"enabled":false,"hour":7}' > "${STATE_DIR}/digest.json" ok "digest pref seeded (off by default)" fi # DIVE-5190: the SAFE box-disk sweep + low-disk alarm, hourly. The sweep # deletes only an allowlist of caches a tool rebuilds (old unheld Chrome temp # entries in /tmp, npm/nvm download caches via their own commands, apt-get # clean, disabled snap revisions) and logs what it freed; the alarm tells the # box owner once when the disk is under 5% free. install.sh comes from main but # the bundle comes from the fleet pin, so the cron is written only when the # INSTALLED bundle carries the verb — an older pin gets no cron rather than an # hourly "unknown command" — and removed if a rollback takes the verb away. if [[ -d /etc/cron.d ]]; then if grep -q 'cmd_disk_tick' "$BIN_DIR/5dive" 2>/dev/null; then mkdir -p /var/log/5dive cat > /etc/cron.d/5dive-disk <<'DISKCRON' # 5dive box disk (DIVE-5190) — hourly: daily SAFE sweep (hourly while under 10% # free), then the once-per-episode low-disk alarm. Log: /var/log/5dive/disk-sweep.log 23 * * * * root /usr/local/bin/5dive disk tick >/dev/null 2>>/var/log/5dive/disk-sweep.log DISKCRON chmod 644 /etc/cron.d/5dive-disk ok "/etc/cron.d/5dive-disk (safe disk sweep + low-disk alarm)" else rm -f /etc/cron.d/5dive-disk fi fi # DIVE-948: cap systemd journal growth. With no explicit limit journald drifts # to the distro default (~10% of disk, up to 4G); on the small cx plans that's # wasteful (boxes were reaching ~500M+/month). Idempotent drop-in, rewritten # every update; applying it back-fills the existing fleet via the daily refresh. # Bounds ONLY logs, never app data. 200M / 14d (tunable). if [[ -d /etc/systemd ]]; then mkdir -p /etc/systemd/journald.conf.d cat > /etc/systemd/journald.conf.d/5dive.conf <<'JOURNALD' # 5dive managed (DIVE-948) — bound journal disk use. Rewritten on every update. [Journal] SystemMaxUse=200M MaxRetentionSec=14d JOURNALD chmod 644 /etc/systemd/journald.conf.d/5dive.conf # Apply the new SystemMaxUse and reclaim immediately (retroactive on boxes # already over the cap). Both best-effort — never fail the update over logs. systemctl restart systemd-journald 2>/dev/null || true journalctl --vacuum-size=200M >/dev/null 2>&1 || true ok "/etc/systemd/journald.conf.d/5dive.conf (journal capped 200M/14d)" fi # By rename: every running seat is executing this file (see replace_by_rename). replace_by_rename "$BIN_DIR/5dive-agent-start" curl -fsSL "$REPO/5dive-agent-start" ok "5dive-agent-start → $BIN_DIR/5dive-agent-start" # DIVE-3965: the unit's ExecStopPost notifier. Installed next to the launcher # because it shares the launcher's constraint — it has to work when the # bundle itself is the thing that is broken. # # OPTIONAL AT THE PIN, and that is the whole reason this is not the bare # `curl -fsSL` the launcher above uses. This file is ADDITIVE and the fleet # pin predates the tag that ships it, so a fail-closed fetch here 404s on # every fresh install and on every box's 04:00Z self-update and aborts the # WHOLE install under `set -e` (DIVE-4349; caught by # scripts/install-pin-compat.sh before it reached a box). A box that does not # get the notifier simply has no ExecStopPost target — which the unit's `-` # prefix already tolerates: the notification is missing, the unit still stops. # # MOVE IT UP into the fail-closed set only once the fleet pin is at or past # the tag that ships it; install-pin-compat grades that on every PR touching # this file, so "the pin has caught up" stays a check result, not a memory. if fetch_optional_at_pin "5dive-agent-stop-notify" "$BIN_DIR/5dive-agent-stop-notify"; then chmod 755 "$BIN_DIR/5dive-agent-stop-notify" ok "5dive-agent-stop-notify → $BIN_DIR/5dive-agent-stop-notify" else # Symmetric with the optional-hook loop below: the bundle at this pin does # not wire it, so a copy left from a newer install would be a file nothing # invokes (the unit template comes from the same pin and carries no # ExecStopPost line there). rm -f "$BIN_DIR/5dive-agent-stop-notify" say "5dive-agent-stop-notify is not shipped at this pin — agent crash notifications stay off on this box until the fleet pin passes the tag that carries it" fi # >>> DIVE-4194 box-side scripts # The helper scripts below used to be three hand-written curl/chmod/ok blocks # right here, and this file was their ONLY writer. The control plane's nightly # pass EXECUTED two of them and replaced neither, so a merged one-line fix to a # helper reached zero existing boxes (measured DIVE-4130). They are enumerated # from host-scripts.manifest now, and 5dive-host-updates.sh reads THAT SAME # FILE out of the release tag — so a script added to the manifest reaches both # the installer and the fleet's nightly, and a script added to neither is # visible as a diff to one list rather than as a missing line in one of two. # # Add a box-side script by adding its name to host-scripts.manifest. # tests/install_box_scripts_manifest_unit.sh refuses a $BIN_DIR write here that # the manifest does not name. local _hs_manifest _hs_list _hs_name _hs_manifest="$(curl -fsSL "$REPO/host-scripts.manifest" 2>/dev/null || true)" # An entry becomes an install destination under $BIN_DIR as root: reject a # slash, a leading dot or anything outside [A-Za-z0-9._-] rather than sanitise # it. Comments and blank lines are dropped; leading/trailing space is trimmed, # never interior space (that would turn "two words" into a passing name). _hs_list="$(printf '%s\n' "$_hs_manifest" \ | sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ | grep -E '^[A-Za-z0-9][A-Za-z0-9._-]*$' || true)" if [[ -z "$_hs_list" ]]; then # Only reachable from a pin that predates the manifest. Named, not silent: # a box-side script added since that pin is NOT installed by this run. echo " ! host-scripts.manifest unreadable at this pin — falling back to the pre-DIVE-4194 list; a box-side script added since that pin is NOT installed by this run" >&2 _hs_list=$'5dive-refresh-plugins.sh\n5dive-stage-fork-plugins.sh\n5dive-refresh-skills.sh' fi while IFS= read -r _hs_name; do [[ -n "$_hs_name" ]] || continue replace_by_rename "$BIN_DIR/$_hs_name" curl -fsSL "$REPO/$_hs_name" ok "$_hs_name → $BIN_DIR/$_hs_name" done <<< "$_hs_list" # <<< DIVE-4194 box-side scripts # >>> DIVE-5373 tool-env shim # The unit's BASH_ENV points HERE, not at the key file. The keys stay 640 # root:claude under /etc/5dive (750), which a sandboxed seat cannot even # traverse, and bash prints "<file>: Permission denied" on every command whose # BASH_ENV it cannot open (only a missing file is silent). This shim is # readable by every seat and sources the keys only when this seat can read # them, so a sandboxed seat gets no keys and no noise. Written inline (no # fetch, so no pin dependency) and BEFORE the unit, so no unit ever points at # a shim that is not there yet. install -d -m 755 "$LIB_DIR" _te_tmp="$(mktemp "${LIB_DIR}/.tool-env.sh.XXXXXX")" cat > "$_te_tmp" <<'TOOLENV' # 5dive tool keys (DIVE-5366/DIVE-5373): every agent's BASH_ENV. Silent when # this seat cannot read the keys. if [ -r /etc/5dive/connectors/tools.sh ]; then . /etc/5dive/connectors/tools.sh; fi # DIVE-5396: a seat installs its own tools with no sudo. npm -g, pip and uv tool # go to ~/.local (bins in ~/.local/bin). npm's default prefix is root-owned /usr, # and Ubuntu marks the system python externally managed, which refuses even # --user installs; a seat's own ~/.local cannot break the system's packages. # DIVE-6021: no PATH-resolved command in this file. Every bash script sources it, # so a test's `#!/bin/bash` stub of a command called here re-enters it without # bound (a fork bomb on the seat). EUID is a bash builtin, and only bash reads # BASH_ENV; the absolute /usr/bin/id is the fallback no PATH stub can shadow. if [ "${EUID:-$(/usr/bin/id -u)}" != 0 ] && [ -n "${HOME:-}" ]; then case ":${PATH:-}:" in *":$HOME/.local/bin:"*) ;; *) PATH="$HOME/.local/bin:${PATH:-/usr/bin:/bin}"; export PATH ;; esac if [ -z "${NPM_CONFIG_PREFIX:-}${npm_config_prefix:-}" ] && [ ! -s "$HOME/.nvm/nvm.sh" ]; then NPM_CONFIG_PREFIX="$HOME/.local"; export NPM_CONFIG_PREFIX fi PIP_BREAK_SYSTEM_PACKAGES=1; export PIP_BREAK_SYSTEM_PACKAGES fi TOOLENV chmod 644 "$_te_tmp" mv -f "$_te_tmp" "$LIB_DIR/tool-env.sh" ok "tool-env shim → $LIB_DIR/tool-env.sh" # <<< DIVE-5373 tool-env shim curl -fsSL "$REPO/systemd/5dive-agent%40.service" -o "$SYSTEMD_DIR/5dive-agent@.service" ok "systemd template installed" # hermes-perms watchdog — hermes resets /home/claude/.hermes to 0700 on # every auth.json/config.yaml write, blocking agent-<name> users (in the # `claude` group) from traversing to venv/bin/hermes. The .path unit # watches the dir; the .service oneshot chmods it back to 0775. curl -fsSL "$REPO/systemd/5dive-hermes-perms.path" -o "$SYSTEMD_DIR/5dive-hermes-perms.path" curl -fsSL "$REPO/systemd/5dive-hermes-perms.service" -o "$SYSTEMD_DIR/5dive-hermes-perms.service" ok "hermes-perms units installed" systemctl daemon-reload # Pre-create the watched dir if it's missing so enabling the path unit # doesn't immediately fail. The /home/claude user is created earlier in # this script's install path; on --upgrade re-runs the dir is already # there. Fail-soft: if /home/claude doesn't exist (atypical), skip. if [[ -d /home/claude ]]; then # setgid 2770: new files inherit the `claude` group, so agent-<name> users # (in that group) can read auth state hermes writes — mirroring the perms # established at host-install time in scripts/install/users.sh. install -d -m 2770 -o claude -g claude /home/claude/.hermes systemctl enable --now 5dive-hermes-perms.path >/dev/null 2>&1 || true fi install -d -m 755 "$LIB_DIR" "$LIB_DIR/skills/notify-user" # Remove the deprecated sender-side PreToolUse mirror: it read the # pre-expansion command string, so it couldn't see heredoc bodies. The # receiver-side userprompt-mirror-inter-agent.sh below replaces it. rm -f "$LIB_DIR/mirror-agent-send.sh" # DIVE-4889: the Telegram Stop hook lives in the telegram plugin # (5dive-ai/5dive-plugins plugins/telegram/hooks/stop-reply-check.ts), which # has owned it since plugin 0.4.4. This shell copy was left installed for # pre-fork agents that no longer exist (0 seats wired it, measured # 2026-09-23), and a fix landed in it by mistake (#1005). Shed it. rm -f "$LIB_DIR/stop-telegram-reply-check.sh" for hook in stop-failure-telegram.sh resume-after-reset.sh run-loop.sh \ pretool-telegram-question.sh pretool-headless-question.sh \ posttool-telegram-relay.sh userprompt-mirror-inter-agent.sh \ stop-mirror-inter-agent.sh push-notify.sh \ sessionstart-resume-context.sh; do curl -fsSL "$REPO/hooks/$hook" -o "$LIB_DIR/$hook" chmod 755 "$LIB_DIR/$hook" ok "$hook" done # >>> DIVE-4349/DIVE-4350 hooks newer than the fleet pin are optional at that pin # These are named by main's copy of this script but may not exist in the # PINNED tag's tree. The bundle at that pin never wires a hook it does not # ship, so skipping is the correct outcome — but ONLY for a real 404, which # is `fetch_optional_at_pin`'s whole contract (see the helper above). # # MOVE A HOOK UP into the fail-closed loop only once the fleet pin is at or # past the tag that ships it. scripts/install-pin-compat.sh grades that on # every PR that touches this file, so "the pin has caught up" is a check # result and not a memory. for hook in stop-browser-teardown.sh; do if fetch_optional_at_pin "hooks/$hook" "$LIB_DIR/$hook"; then chmod 755 "$LIB_DIR/$hook" ok "$hook" else rm -f "$LIB_DIR/$hook" fi done # <<< DIVE-4349/DIVE-4350 # DIVE-4919: notify-user is staged FROM THE TELEGRAM PLUGIN, not from this # repo — core's own skills/notify-user copy was stale and is gone. This staged # file is only the fallback for a channel plugin that ships no copy of its own # (telegram-pi, telegram-opencode); claude seats get the skill from the plugin # itself and codex/grok/agy seats from their per-channel plugin # (seed_notify_user_skill). 5dive-api's update.sh still reads this path. # MERGE-DEPLOYS (DIVE-2288): a BRANCH file, like the plugin tarballs below. # Fail-soft like them too: a miss keeps the previously staged copy. NOTIFY_USER_SKILL_URL="${NOTIFY_USER_SKILL_URL:-https://raw.githubusercontent.com/$GH_ORG/5dive-plugins/main/plugins/telegram/skills/notify-user/SKILL.md}" _nu_tmp=$(mktemp) if curl -fsSL "$NOTIFY_USER_SKILL_URL" -o "$_nu_tmp" 2>/dev/null && grep -q '^name: notify-user' "$_nu_tmp"; then install -m 644 "$_nu_tmp" "$LIB_DIR/skills/notify-user/SKILL.md" ok "notify-user skill (telegram plugin)" else echo "warn: failed to stage notify-user from $NOTIFY_USER_SKILL_URL — keeping any previously staged copy" >&2 fi rm -f "$_nu_tmp" # Stage 5dive-cli skill (from the skills repo — separate repo from this # CLI's source). Unlike notify-user which is a single SKILL.md, this one # ships SKILL.md plus a references/ subdir, so we tarball the whole subdir # in one shot. update.sh's per-agent refresh loop syncs from here, so an # existing agent's 5dive-cli skill picks up upstream changes on the daily # 03:00 cron instead of being frozen at agent-create time. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts the 5dive-cli skill, from 5dive-ai/skills on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The SKILLS_REPO_TARBALL override exists to pin it; the DEFAULT is mutable. SKILLS_REPO_TARBALL="${SKILLS_REPO_TARBALL:-https://github.com/$GH_ORG/skills/archive/refs/heads/main.tar.gz}" install -d -m 755 "$LIB_DIR/skills/5dive-cli" _skill_tmp=$(mktemp -d) if curl -fsSL "$SKILLS_REPO_TARBALL" \ | tar -xz -C "$_skill_tmp" --strip-components=1 'skills-main/5dive-cli' 2>/dev/null \ && [ -f "$_skill_tmp/5dive-cli/SKILL.md" ]; then # cp -a preserves the source's references/ dir layout; --no-target-directory # would clobber, so use the trailing slash + dot to copy contents. cp -a "$_skill_tmp/5dive-cli/." "$LIB_DIR/skills/5dive-cli/" find "$LIB_DIR/skills/5dive-cli" -type f -exec chmod 644 {} + find "$LIB_DIR/skills/5dive-cli" -type d -exec chmod 755 {} + ok "5dive-cli skill" else echo "warn: failed to stage 5dive-cli skill from $SKILLS_REPO_TARBALL — existing agents won't see updates until next try" >&2 fi rm -rf "$_skill_tmp" # Stage the Codex dispatcher plus its dashboard adapter (from the # 5dive-plugins repo). Codex has # no plugin marketplace, so unlike claude there's nothing to install per # agent — its dispatcher and adapters run from this one shared checkout. # 5dive-agent-start launches $LIB_DIR/telegram-codex/dispatcher.ts, which # resolves per-agent state from $HOME and supervises the selected adapters. # Whole-subdir tarball (like 5dive-cli above), then `bun install` both runtime # dependency trees. cp -a overlays # the source onto any existing copy so node_modules survives across --upgrade # refreshes; bun reconciles deps against the (possibly updated) lockfile. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts telegram-codex, from 5dive-ai/5dive-plugins on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The CODEX_PLUGIN_TARBALL override exists to pin it; the DEFAULT is mutable. CODEX_PLUGIN_TARBALL="${CODEX_PLUGIN_TARBALL:-https://github.com/$GH_ORG/5dive-plugins/archive/refs/heads/main.tar.gz}" _cdx_tmp=$(mktemp -d) if curl -fsSL "$CODEX_PLUGIN_TARBALL" \ | tar -xz -C "$_cdx_tmp" --strip-components=1 \ '5dive-plugins-main/plugins/telegram-codex' \ '5dive-plugins-main/plugins/dashboard' 2>/dev/null \ && [ -f "$_cdx_tmp/plugins/telegram-codex/dispatcher.ts" ] \ && [ -f "$_cdx_tmp/plugins/dashboard/server.ts" ]; then install -d -m 755 "$LIB_DIR/telegram-codex" "$LIB_DIR/dashboard" cp -a "$_cdx_tmp/plugins/telegram-codex/." "$LIB_DIR/telegram-codex/" cp -a "$_cdx_tmp/plugins/dashboard/." "$LIB_DIR/dashboard/" # bun lives in claude's home; install deps as claude (needs write on the # staged dir), then make the tree world-readable+traversable so every # agent-<name> user (incl. ones outside the claude group) can run # server.ts + the hooks. if id -u claude >/dev/null 2>&1; then chown -R claude:claude "$LIB_DIR/telegram-codex" "$LIB_DIR/dashboard" if sudo -u claude -H bash -lc \ "cd $(printf %q "$LIB_DIR/telegram-codex") && bun install --production --ignore-scripts --no-progress --no-summary && cd $(printf %q "$LIB_DIR/dashboard") && bun install --production --ignore-scripts --no-progress --no-summary" >/dev/null 2>&1; then chmod -R a+rX "$LIB_DIR/telegram-codex" "$LIB_DIR/dashboard" ok "Codex channel dispatcher and dashboard adapter" else echo "warn: bun install for Codex channel dispatcher failed — codex channel agents won't have the bridge until the next successful refresh" >&2 fi else echo "warn: no claude user — skipping telegram-codex bun install" >&2 fi else echo "warn: failed to stage Codex dispatcher and dashboard adapter from $CODEX_PLUGIN_TARBALL — Codex channels won't be available until the next successful refresh" >&2 fi rm -rf "$_cdx_tmp" # Stage the telegram-grok plugin — same shape as telegram-codex above. grok # also has no plugin marketplace; its MCP server + lifecycle hooks run from # this one shared checkout via absolute paths written into ~/.grok/config.toml # by 5dive-agent-start. Override the tarball with GROK_PLUGIN_TARBALL for # offline / test installs. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts telegram-grok, from 5dive-ai/5dive-plugins on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The GROK_PLUGIN_TARBALL override exists to pin it; the DEFAULT is mutable. GROK_PLUGIN_TARBALL="${GROK_PLUGIN_TARBALL:-https://github.com/$GH_ORG/5dive-plugins/archive/refs/heads/main.tar.gz}" _grk_tmp=$(mktemp -d) if curl -fsSL "$GROK_PLUGIN_TARBALL" \ | tar -xz -C "$_grk_tmp" --strip-components=1 '5dive-plugins-main/plugins/telegram-grok' 2>/dev/null \ && [ -f "$_grk_tmp/plugins/telegram-grok/server.ts" ]; then install -d -m 755 "$LIB_DIR/telegram-grok" cp -a "$_grk_tmp/plugins/telegram-grok/." "$LIB_DIR/telegram-grok/" if id -u claude >/dev/null 2>&1; then chown -R claude:claude "$LIB_DIR/telegram-grok" if sudo -u claude -H bash -lc "cd $(printf %q "$LIB_DIR/telegram-grok") && bun install --production --ignore-scripts --no-progress --no-summary" >/dev/null 2>&1; then chmod -R a+rX "$LIB_DIR/telegram-grok" ok "telegram-grok plugin" else echo "warn: bun install for telegram-grok failed — grok+telegram agents won't have the bridge until the next successful refresh" >&2 fi else echo "warn: no claude user — skipping telegram-grok bun install" >&2 fi else echo "warn: failed to stage telegram-grok from $GROK_PLUGIN_TARBALL — grok+telegram won't be available until the next successful refresh" >&2 fi # Stage the telegram-agy plugin — same shape as telegram-grok above. # antigravity (agy) has no plugin marketplace, so every agy+telegram agent # shares this one checkout via absolute paths written into the GLOBAL # ~/.gemini/config/{mcp_config.json,hooks.json} by 5dive-agent-start (agy # doesn't auto-load a plugin's mcp_config/hooks — only skills/agents). # Override the tarball with AGY_PLUGIN_TARBALL for offline / pinned installs. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts telegram-agy, from 5dive-ai/5dive-plugins on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The AGY_PLUGIN_TARBALL override exists to pin it; the DEFAULT is mutable. AGY_PLUGIN_TARBALL="${AGY_PLUGIN_TARBALL:-https://github.com/$GH_ORG/5dive-plugins/archive/refs/heads/main.tar.gz}" _agy_tmp="$(mktemp -d)" if curl -fsSL "$AGY_PLUGIN_TARBALL" \ | tar -xz -C "$_agy_tmp" --strip-components=1 '5dive-plugins-main/plugins/telegram-agy' 2>/dev/null \ && [ -f "$_agy_tmp/plugins/telegram-agy/server.ts" ]; then install -d -m 755 "$LIB_DIR/telegram-agy" cp -a "$_agy_tmp/plugins/telegram-agy/." "$LIB_DIR/telegram-agy/" if id -u claude >/dev/null 2>&1; then chown -R claude:claude "$LIB_DIR/telegram-agy" if sudo -u claude -H bash -lc "cd $(printf %q "$LIB_DIR/telegram-agy") && bun install --production --ignore-scripts --no-progress --no-summary" >/dev/null 2>&1; then chmod -R a+rX "$LIB_DIR/telegram-agy" ok "telegram-agy plugin" else echo "warn: bun install for telegram-agy failed — antigravity+telegram agents won't have the bridge until the next successful refresh" >&2 fi else echo "warn: no claude user — skipping telegram-agy bun install" >&2 fi else echo "warn: failed to stage telegram-agy from $AGY_PLUGIN_TARBALL — antigravity+telegram won't be available until the next successful refresh" >&2 fi rm -rf "$_agy_tmp" rm -rf "$_grk_tmp" # Stage the telegram-opencode plugin — same shape as telegram-agy above. # opencode has no plugin marketplace; its telegram bridge is a standalone # long-running relay (server.ts over `opencode serve`) launched by # 5dive-agent-start from this one shared checkout via absolute paths. Without # this, opencode+telegram agents can't be provisioned (install_channel_for_ # opencode_agent's plugin-dir check fails) — i.e. opencode telegram is a # no-op on customer boxes until staged. Override with OPENCODE_PLUGIN_TARBALL # for offline / pinned installs. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts telegram-opencode, from 5dive-ai/5dive-plugins on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The OPENCODE_PLUGIN_TARBALL override exists to pin it; the DEFAULT is mutable. OPENCODE_PLUGIN_TARBALL="${OPENCODE_PLUGIN_TARBALL:-https://github.com/$GH_ORG/5dive-plugins/archive/refs/heads/main.tar.gz}" _ocode_tmp="$(mktemp -d)" if curl -fsSL "$OPENCODE_PLUGIN_TARBALL" \ | tar -xz -C "$_ocode_tmp" --strip-components=1 '5dive-plugins-main/plugins/telegram-opencode' 2>/dev/null \ && [ -f "$_ocode_tmp/plugins/telegram-opencode/server.ts" ]; then install -d -m 755 "$LIB_DIR/telegram-opencode" cp -a "$_ocode_tmp/plugins/telegram-opencode/." "$LIB_DIR/telegram-opencode/" if id -u claude >/dev/null 2>&1; then chown -R claude:claude "$LIB_DIR/telegram-opencode" if sudo -u claude -H bash -lc "cd $(printf %q "$LIB_DIR/telegram-opencode") && bun install --production --ignore-scripts --no-progress --no-summary" >/dev/null 2>&1; then chmod -R a+rX "$LIB_DIR/telegram-opencode" ok "telegram-opencode plugin" else echo "warn: bun install for telegram-opencode failed — opencode+telegram agents won't have the bridge until the next successful refresh" >&2 fi else echo "warn: no claude user — skipping telegram-opencode bun install" >&2 fi else echo "warn: failed to stage telegram-opencode from $OPENCODE_PLUGIN_TARBALL — opencode+telegram won't be available until the next successful refresh" >&2 fi rm -rf "$_ocode_tmp" # Stage the telegram-pi plugin — same shape as telegram-opencode above. pi # (earendil-works/pi) is EXTENSION-based with no plugin marketplace; its # telegram bridge is a standalone long-running relay (server.ts) launched by # 5dive-agent-start from this one shared checkout via absolute paths. Without # this, pi+telegram agents can't be provisioned (install_channel_for_pi_ # agent's plugin-dir check fails) — i.e. pi telegram is a no-op on customer # boxes until staged. Override with PI_PLUGIN_TARBALL for offline / pinned # installs. # MERGE-DEPLOYS (DIVE-2288): a BRANCH tarball, not $REPO. Merging to that # repo's main puts telegram-pi, from 5dive-ai/5dive-plugins on every box at its # next --upgrade, root-installed, with no tag and no review in THIS repo. # The PI_PLUGIN_TARBALL override exists to pin it; the DEFAULT is mutable. PI_PLUGIN_TARBALL="${PI_PLUGIN_TARBALL:-https://github.com/$GH_ORG/5dive-plugins/archive/refs/heads/main.tar.gz}" _pi_tmp="$(mktemp -d)" if curl -fsSL "$PI_PLUGIN_TARBALL" \ | tar -xz -C "$_pi_tmp" --strip-components=1 '5dive-plugins-main/plugins/telegram-pi' 2>/dev/null \ && [ -f "$_pi_tmp/plugins/telegram-pi/server.ts" ]; then install -d -m 755 "$LIB_DIR/telegram-pi" cp -a "$_pi_tmp/plugins/telegram-pi/." "$LIB_DIR/telegram-pi/" if id -u claude >/dev/null 2>&1; then chown -R claude:claude "$LIB_DIR/telegram-pi" if sudo -u claude -H bash -lc "cd $(printf %q "$LIB_DIR/telegram-pi") && bun install --production --ignore-scripts --no-progress --no-summary" >/dev/null 2>&1; then chmod -R a+rX "$LIB_DIR/telegram-pi" ok "telegram-pi plugin" else echo "warn: bun install for telegram-pi failed — pi+telegram agents won't have the bridge until the next successful refresh" >&2 fi else echo "warn: no claude user — skipping telegram-pi bun install" >&2 fi else echo "warn: failed to stage telegram-pi from $PI_PLUGIN_TARBALL — pi+telegram won't be available until the next successful refresh" >&2 fi rm -rf "$_pi_tmp" # CLAUDE.md fragment that preseed_claude_agent drops into a telegram-paired # agent's $HOME/.claude/ so the per-turn reply mandate + AskUserQuestion / # ExitPlanMode warning ride with the agents that actually need them — not # the shared projects-level file every agent reads. curl -fsSL "$REPO/telegram-agent-CLAUDE.md" -o "$LIB_DIR/telegram-agent-CLAUDE.md" chmod 644 "$LIB_DIR/telegram-agent-CLAUDE.md" ok "telegram-agent-CLAUDE.md" # CLAUDE.md fragment appended to every claude-type agent's $HOME/.claude/ # CLAUDE.md at create: the self-gated Fable-orchestrator + per-subagent # model-tiering default (DIVE-899 — inert unless the session model is Fable). # Fail-soft: a missing/transient-404 content fragment shouldn't hard-abort # the whole install (curl -f exits 37 on a file:// bundle that omits it, which # is what reddened install-smoke — DIVE-938). The file is inert unless the # session model is Fable (DIVE-899). if curl -fsSL "$REPO/model-tiering-CLAUDE.md" -o "$LIB_DIR/model-tiering-CLAUDE.md"; then chmod 644 "$LIB_DIR/model-tiering-CLAUDE.md" ok "model-tiering-CLAUDE.md" else echo "warn: failed to stage model-tiering-CLAUDE.md — Fable model-tiering default won't apply until the next refresh" >&2 fi # DIVE-1613: terse-by-default operational-comms fragment appended to every # claude-type agent's $HOME/.claude/CLAUDE.md at create (preseed_claude_agent). # A persona/pack "be concise" line reads as craft voice and does NOT enforce # terse operational chat, so this ships a separate universal rule. Fail-soft # like the fragments above — a missing fragment just means the rule applies at # the next refresh, not a broken agent. if curl -fsSL "$REPO/operational-comms-CLAUDE.md" -o "$LIB_DIR/operational-comms-CLAUDE.md"; then chmod 644 "$LIB_DIR/operational-comms-CLAUDE.md" ok "operational-comms-CLAUDE.md" else echo "warn: failed to stage operational-comms-CLAUDE.md — terse-comms default won't apply until the next refresh" >&2 fi # DIVE-6635: the team block appended to every claude-type agent's # $HOME/.claude/CLAUDE.md at create, and refreshed on existing seats below # (sync_seat_claudemd). Fail-soft like the fragments above. if curl -fsSL "$REPO/team-CLAUDE.md" -o "$LIB_DIR/team-CLAUDE.md"; then chmod 644 "$LIB_DIR/team-CLAUDE.md" ok "team-CLAUDE.md" else echo "warn: failed to stage team-CLAUDE.md — agents keep their current team instructions until the next refresh" >&2 fi # DIVE-1210: project subagent that overrides the harness's built-in Explore # agent, pinning it to haiku instead of inheriting the session's model. CC # >=2.1.198 has Explore inherit the main conversation's model (capped at # Opus) rather than always running on Haiku — and every 5dive claude agent # is pinned to opus (preseed_claude_agent), so every un-overridden Explore # call runs full Opus. preseed_claude_agent drops this into each new agent's # $HOME/.claude/agents/explore.md at create. Fail-soft like the CLAUDE.md # fragments above — a missing fragment just means Explore stays on the # session's inherited model until the next refresh, not a broken agent. if curl -fsSL "$REPO/explore-agent.md" -o "$LIB_DIR/explore-agent.md"; then chmod 644 "$LIB_DIR/explore-agent.md" ok "explore-agent.md" else echo "warn: failed to stage explore-agent.md — the haiku-pinned Explore override won't apply until the next refresh" >&2 fi # DIVE-4196 — TEAM TEMPLATES ARE NO LONGER STAGED. They moved to the same # marketplace registry the character packs come from (<org>/5dive-marketplace, # under teams/) and `5dive team` reads them live. # # Do not restore a staging loop here. Staging at install time is the defect, # not the delivery: a box that has already installed never receives a template # published afterwards, and the hand-maintained list above it drifted from # index.json twice (#807 deploy-team, #808 distribution) — each time # advertising a slug through `team ls` that `team import` then refused. # DIVE-4202 — NO bundled plugin marketplace is staged here any more. # # The CLI used to ship `plugins/voice` and `plugins/browser` in its own repo # and this block curled them onto the box as a local marketplace named # "5dive". That made those two the only plugins whose fix reached a customer # on a CLI release rather than on a publish, and it is how the browser plugin # shipped uninstallable on 0.28.0 (DIVE-4126). Both now live in # 5dive-ai/5dive-plugins with every other plugin, and `5dive plugin` # registers that ONE registry itself on first use # (src/cmd_plugin.sh:_plugin_register_registry) — so there is nothing for the # installer to stage, and no per-file list here to drift from what plugins/ # contains. # /etc/claude-code/managed-settings.json — channel-plugin allowlist. # Claude reads a default Anthropic-blessed ledger when this file is # absent, which permits telegram@claude-plugins-official but NOT our # fork. The moment a custom allowlist exists, claude ignores the # default ledger entirely — so we list BOTH the 5dive fork and the # upstream entry, plus discord upstream. Existing agents pinned to # claude-plugins-official keep working; new agents on 5dive-plugins # are now allowlisted. Use install -m to preserve the file mode and # never clobber a customised entry: skip if the operator already # wrote one (e.g. with extra plugins of their own). # channelsEnabled: claude code 2.1.150+ requires this flag for any # allowedChannelPlugins entry to actually take effect. Without it, # the allowlist is silently inert and inbound channel messages # don't reach the session. # syncClaudeAiSkills / syncClaudeAiPlugins (DIVE-4697): OFF. Claude Code # 2.1.275 syncs the skills and plugins enabled on the claude.ai ACCOUNT into # every session signed in with it. Our auth profiles are shared across seats # and boxes, so one toggle in that account's web UI would land code in every # seat on the profile — while what runs in a seat is a BOX decision # (/dashboard/plugins, DIVE-4434). allowedChannelPlugins does NOT cover this # path: it gates a synced plugin's CHANNEL only, so skills, commands, hooks, # agents and MCP servers from a synced plugin load like ones we installed. # Only `false` is honoured (the feature is enabled server-side), and on a # claude.ai TEAM/Enterprise login the org's remote managed settings override # this file entirely — there the control is the org admin's, not this key. # Mirrors FIVEDIVE_MANAGED_SYNC_OFF_JSON in src/header.sh, which this # curl-piped script cannot source; tests/managed_settings_selfheal_unit.sh # diffs the two copies below against that constant. # requiredMinimumVersion (DIVE-133): a known-good CC floor. CC 2.1.163+ refuses # to start below it; older CC ignores the key, so it can never brick a box. # Pure downgrade guardrail (botched rollback / accidental pin-back) — every box # runs latest >= floor. NO requiredMaximumVersion: our installer chases latest # (apps.sh + nightly soft-updates.sh), so a ceiling would brick boxes once # upstream passes it, not hold them at a version. Release-safety from a bad # upstream CC belongs in installer-pinning, not a managed-settings ceiling. install -d -m 755 /etc/claude-code local msj=/etc/claude-code/managed-settings.json if [[ ! -f "$msj" ]]; then cat > "$msj" <<'MANAGED' { "channelsEnabled": true, "requiredMinimumVersion": "2.1.163", "syncClaudeAiSkills": false, "syncClaudeAiPlugins": false, "allowedChannelPlugins": [ {"plugin": "telegram", "marketplace": "5dive-plugins"}, {"plugin": "dashboard", "marketplace": "5dive-plugins"}, {"plugin": "buzz", "marketplace": "5dive-plugins"}, {"plugin": "telegram", "marketplace": "claude-plugins-official"}, {"plugin": "discord", "marketplace": "claude-plugins-official"} ] } MANAGED chmod 644 "$msj" ok "/etc/claude-code/managed-settings.json (new)" else # DIVE-1816: reconcile an EXISTING file. install.sh only wrote the allowlist # on first install, so boxes provisioned before the dashboard channel shipped # (or before any 5dive fork was listed) keep a stale list — and because ANY # custom allowlist makes Claude ignore its default ledger, an unlisted channel # is silently DROPPED (dashboard pings never reached personal-account agents, # e.g. claude-leaf). Idempotently ensure channelsEnabled:true and that BOTH # 5dive fork channels are present, WITHOUT clobbering operator additions or # the upstream/official entries. On a team box the org's remote managed- # settings override this local file entirely, so the merge is inert there; # on a personal/self-hosted box this local file IS the self-approve allowlist. # Only rewrites (via a temp + install -m, mode-preserving) when something # actually changed, and skips silently if jq is unavailable or the file isn't # valid JSON (never brick a hand-managed settings file). if command -v jq >/dev/null 2>&1 && jq -e . "$msj" >/dev/null 2>&1; then local msj_tmp msj_tmp=$(mktemp) # DIVE-4697: the two sync keys are ASSIGNED here, so an existing box gains # them on its next nightly run of this installer. Assigned and not merged: # `false` is the only value Claude honours, so a box carrying `true` is # corrected rather than preserved. Everything else in the document — the # operator's own keys and allowlist entries — survives untouched, which is # what makes a hand edit ahead of this change durable (it is exactly how # the two keys placed on the control host by hand on 2026-09-20 persist). if jq ' .channelsEnabled = true | .syncClaudeAiSkills = false | .syncClaudeAiPlugins = false | .allowedChannelPlugins = ((.allowedChannelPlugins // []) as $have | $have + ([{"plugin":"telegram","marketplace":"5dive-plugins"}, {"plugin":"dashboard","marketplace":"5dive-plugins"}, {"plugin":"buzz","marketplace":"5dive-plugins"}] | map(select(. as $need | ($have | any(.plugin == $need.plugin and .marketplace == $need.marketplace)) | not)))) ' "$msj" > "$msj_tmp" 2>/dev/null && [[ -s "$msj_tmp" ]]; then if ! jq -e --slurpfile a "$msj_tmp" '. == $a[0]' "$msj" >/dev/null 2>&1; then install -m 644 "$msj_tmp" "$msj" ok "/etc/claude-code/managed-settings.json (reconciled: +5dive channels / channelsEnabled / claude.ai sync off)" else ok "/etc/claude-code/managed-settings.json (kept existing; already current)" fi else ok "/etc/claude-code/managed-settings.json (kept existing; reconcile skipped)" fi rm -f "$msj_tmp" else ok "/etc/claude-code/managed-settings.json (kept existing; jq/JSON unavailable, no reconcile)" fi fi # DIVE-4406 — reconcile one marker-delimited block from a published source file # into a live file, leaving every byte outside the markers alone. # sync_managed_block <live-file> <source-url-or-path> <marker-id> # Markers are HTML comments: `<!-- <id>:begin …-->` … `<!-- <id>:end -->`. # Returns 1 (and writes nothing) if the source cannot be read or carries no # block — a fetch failure must never truncate a host's file. sync_managed_block() { local live="$1" src="$2" id="$3" tmp block tmp="$(mktemp)" || return 1 if [[ "$src" == http*://* ]]; then curl -fsSL "$src" -o "$tmp" || { rm -f "$tmp"; return 1; } else cp "$src" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }; fi block="$(awk -v id="$id" ' index($0, "<!-- " id ":begin") { inb = 1 } inb { print } index($0, "<!-- " id ":end") { inb = 0 }' "$tmp")" rm -f "$tmp" [[ -n "$block" ]] || return 1 [[ -f "$live" ]] || { printf '%s\n' "$block" > "$live"; return 0; } local out; out="$(mktemp)" || return 1 if grep -qF "<!-- ${id}:begin" "$live"; then # Replace in place: everything outside the markers is copied byte for byte; # the old block is dropped and the new one printed where it stood. local bf; bf="$(mktemp)" || { rm -f "$out"; return 1; } printf '%s\n' "$block" > "$bf" awk -v id="$id" -v bf="$bf" ' BEGIN { while ((getline l < bf) > 0) nb = nb l "\n" } index($0, "<!-- " id ":begin") { printf "%s", nb; skip = 1; next } skip { if (index($0, "<!-- " id ":end")) skip = 0; next } { print } ' "$live" > "$out" 2>/dev/null || { rm -f "$out" "$bf"; return 1; } rm -f "$bf" else { cat "$live"; printf '\n%s\n' "$block"; } > "$out" fi [[ -s "$out" ]] || { rm -f "$out"; return 1; } cat "$out" > "$live"; rm -f "$out" } # DIVE-6635 — bring one existing claude seat's own CLAUDE.md up to date: # sync_seat_claudemd <seat CLAUDE.md> <seat user> <team block file> # That file is written once, at create, so without this only seats created after # a release would learn how to reach a teammate (a lead that never learned it used # Claude Code's own ListAgents, saw nobody, and worked alone for days). It also # renames the old model-tiering heading, which a lead on another model read as # "orchestrator mode is off, I work alone"; the new text is the old one with only # those two lines changed, so a migrated seat matches a new one byte for byte. # Written through the seat's own user, so a symlink the seat planted reaches only # what the seat itself could write. Returns 1 and changes nothing on any failure. sync_seat_claudemd() { local live="$1" user="$2" team="$3" tmp [[ -f "$live" && ! -L "$live" ]] || return 1 tmp="$(mktemp)" || return 1 cp "$live" "$tmp" || { rm -f "$tmp"; return 1; } sed -i -e 's/^# Model tiering (orchestrator + minions)$/# Subagent model choice (Fable sessions only)/' \ -e 's/^Fable acts as the orchestrator: plan, decide/On Fable, plan, decide/' "$tmp" \ && sync_managed_block "$tmp" "$team" 5dive:team || { rm -f "$tmp"; return 1; } if ! cmp -s "$tmp" "$live"; then sudo -u "$user" tee "$live" < "$tmp" >/dev/null || { rm -f "$tmp"; return 1; } fi rm -f "$tmp" } # DIVE-5416 — is <file> an UNEDITED copy of a projects-CLAUDE.md this repo has # shipped? Every agent loads that file on every turn, so a box left on an old, # longer copy pays for it on each message. The test is the text OUTSIDE the two # blocks this file owns (those are synced below anyway), blank lines dropped, # against the sum of every shipped version. Any other text — a host's own edit, # another tool's marked block — misses the list and the file is kept as is. # Changing the text outside the blocks? ADD its sum here (never remove one): # tests/projects_claudemd_lean_unit.sh reds until the current file is listed. projects_claudemd_is_stock() { # <file> local sum [[ -f "$1" ]] || return 1 sum="$(awk '/^<!-- 5dive:(task-lifecycle|hired-agents):begin/ { s = 1 } !s { print } /^<!-- 5dive:(task-lifecycle|hired-agents):end/ { s = 0 }' "$1" \ | grep -v '^[[:space:]]*$' | sha256sum)" || return 1 case "${sum%% *}" in fdd8bb84c3a2f6c35e01b58696734a316d035e037eb99241f285eec76779a09d|\ 67484672345293fadff5a975beca06b6626bb2ec7736568ae1f2db2ab9b1aa40|\ 17324e6523d9ad8ce41c2b2ab34984adb65041dfaa12509731ab82624c6411f4|\ 3094340c2abc6b8af6548caca2a6733d87b0ffbee9c4118ce0e4e8924a006ced|\ 2859873dc0b263a76a42df8a38e42b7cf16f576b63ed18f3721369537b7a0e60|\ a43cd24e748c372a8d0fd8cf430446dc3b30860b84b0bb7b8d2da76e1186cf0c|\ f5c59ca93327572eeb40b504848bb86ba71d8148c02e8d1e01965d93e8331dbd|\ bf1882931ed305ea13fc8331f4007183ad7c2a841ed96ae7b5eb0eba4ded3d54|\ 220c10d2d0c0e4050ac655d5ddbeffbb6eaaea38f75028fd48ff57410081a945|\ bc542aafff4db66b5d917ba9b782fb0892e002addf26d16ad2cb539134ee489f|\ cddfb0e1502ae9050efa83082db648e05ecd1f0e9708157d75773ff8b8bad703|\ 8c47dab5990cb1bb1e0664c35e811b29b6bb4c461dee877cd273d7ee6a8a6b0a|\ 0e8345ebdd9ba6ec2bbbcfcc93be75ae0da7e0078015439900d95f64c2dff475) return 0 ;; esac return 1 } # Drop a slim projects-level CLAUDE.md so every agent spawned on this host # picks up baseline self-management guidance (project layout, sudo, where # the agent's own settings live, the host CLI). Written on first install, and # (DIVE-5416) over an unedited stock copy of an older version — never over a # customised file. Fetched to a temp file first, so a failed fetch leaves the # live one alone. Symlink AGENTS.md so non-claude agent types (codex, …) see # the same instructions. install -d -m 755 -o claude -g claude /home/claude/projects if [[ ! -f /home/claude/projects/CLAUDE.md ]] || projects_claudemd_is_stock /home/claude/projects/CLAUDE.md; then _pcm_tmp="$(mktemp)" if curl -fsSL "$REPO/projects-CLAUDE.md" -o "$_pcm_tmp" && [[ -s "$_pcm_tmp" ]]; then # DIVE-5396: the hired-agents block is for boxes 5dive built (see below). [[ -f /etc/5dive/provisioning.env ]] \ || sed -i '/<!-- 5dive:hired-agents:begin/,/<!-- 5dive:hired-agents:end/d' "$_pcm_tmp" if cmp -s "$_pcm_tmp" /home/claude/projects/CLAUDE.md; then ok "projects/CLAUDE.md (current)" else install -m 644 -o claude -g claude "$_pcm_tmp" /home/claude/projects/CLAUDE.md ok "projects/CLAUDE.md" fi else echo "warn: could not fetch projects-CLAUDE.md — projects/CLAUDE.md left as it was" >&2 fi rm -f "$_pcm_tmp" else ok "projects/CLAUDE.md (kept existing)" fi # DIVE-4406 — THE LIFECYCLE CONTRACT IS A MANAGED BLOCK, NOT A FIRST-INSTALL FILE. # The heartbeat dispatch stopped repeating the contract in every /goal and now # points at this file. "Only on first install" would mean every existing host # keeps a CLAUDE.md without it — the nudge would cite a section that is not # there, which is worse than the repetition it replaced. So the block between # the two markers is reconciled on EVERY install: replaced if present, appended # if absent, and everything outside the markers (a host's customisation) is # left exactly as it was. sync_managed_block /home/claude/projects/CLAUDE.md "$REPO/projects-CLAUDE.md" 5dive:task-lifecycle \ && ok "projects/CLAUDE.md (task-lifecycle block synced)" \ || echo "warn: could not sync the task-lifecycle block into projects/CLAUDE.md — the heartbeat dispatch cites it" >&2 # DIVE-5396: what a hired agent can do without root (hire by link, install a # tool, publish an app, ask its lead). Only on a box 5dive built: hire-link # needs the box's 5dive account, and a self-hosted host keeps its own rules. # Synced by the CLI that ships the verbs, so the text never names a verb the # box does not have. if [[ -f /etc/5dive/provisioning.env ]]; then sync_managed_block /home/claude/projects/CLAUDE.md "$REPO/projects-CLAUDE.md" 5dive:hired-agents \ && ok "projects/CLAUDE.md (hired-agents block synced)" \ || echo "warn: could not sync the hired-agents block into projects/CLAUDE.md" >&2 fi # DIVE-6635: every registered claude seat gets the current team block. if [[ -s "$LIB_DIR/team-CLAUDE.md" && -f "$STATE_DIR/agents.json" ]]; then _team_n=0 while IFS=$'\t' read -r _team_seat _team_type; do [[ "$_team_type" == claude && "$_team_seat" =~ ^[a-z0-9][a-z0-9-]*$ ]] || continue id "agent-${_team_seat}" >/dev/null 2>&1 || continue sync_seat_claudemd "/home/agent-${_team_seat}/.claude/CLAUDE.md" "agent-${_team_seat}" "$LIB_DIR/team-CLAUDE.md" \ && _team_n=$((_team_n + 1)) done < <(jq -r '.agents | to_entries[] | [.key, (.value.type // "claude")] | @tsv' "$STATE_DIR/agents.json" 2>/dev/null) ok "team block current on ${_team_n} claude seat(s)" fi if [[ ! -e /home/claude/projects/AGENTS.md ]]; then ln -sfn CLAUDE.md /home/claude/projects/AGENTS.md chown -h claude:claude /home/claude/projects/AGENTS.md fi # Pre-push PII guard (DIVE-1797 enforcement, DIVE-2788 portable mode, # DIVE-2803 provisioning reach). TWO SEPARATE ARTIFACTS, in this order: # # 1. the GUARD HOME (/usr/local/share/5dive/pii-guard) — the ONE host copy # of the portable hook + scanner + denylist that every portable install # reads. It is staged below from $REPO. # 2. the box's own 5dive-ai/5dive CHECKOUT, if it has one, pointed at its # IN-REPO hooks (a relative core.hooksPath) — the loop further down. # # WHY 1 IS STAGED FROM $REPO AND NOT FROM A CHECKOUT. Until DIVE-2803 the only # writer of the guard home was `install-pii-push-guard.sh` running out of a # checkout of this repo, and a freshly provisioned box HAS NO SUCH CHECKOUT: # install.sh installs a released bundle and stages skills from tarballs, and # `git clone` appears nowhere in this file. So the loop below iterated zero # times and the guard home was never created — the whole PII block was a no-op # on precisely the boxes provisioning creates, which is the "fleet-wide decays # from the day it is written" defect this row exists to close. # # WHAT IS DELIBERATELY NOT DONE HERE: `scripts/pii-guard-fleet.sh --install` # is NOT called from this path (DIVE-2803 gate, answered B on 2026-08-06). # That tool's blast radius is bounded by an EPERM-by-owner branch that leaves # a checkout it cannot write alone and REPORTS it. install.sh runs as ROOT, # where that branch can never fire: root writes every uid's .git/config # successfully, so nothing is left to report and nothing stops it. Wiring it # here would reach every agent uid's checkout on every box and every update — # strictly wider than the claude-owned radius it was reviewed against. It # stays available, and stays report-only. # # Best-effort + idempotent throughout: safe to re-run, and it runs on the # daily update too, because this whole function is the --upgrade path. That # matters on its own — the guard home is the single update point for the # denylist, so a box that installs once and never re-syncs grades against a # frozen list, which is the drift the host-path design exists to avoid. # # >>> DIVE-2803 guard-home staging (extracted verbatim by tests/pii_guard_fleet_unit.sh) # Mirror the repo's own layout under $LIB_DIR so install-pii-push-guard.sh # resolves its three sources from $SELF_DIR/.. unchanged — no second copy of # the "which files make a guard home" list, which would be a second thing to # keep in step. PII_GUARD_SRC_SHA carries the provenance a staged tree cannot # read from a .git it does not have; empty is honest and stamps UNKNOWN. _pg_src="$LIB_DIR/pii-guard-src" _pg_tmp="$(mktemp -d)" if install -d -m 755 "$_pg_tmp/scripts/git-hooks-portable" "$_pg_tmp/.github" \ && curl -fsSL "$REPO/scripts/install-pii-push-guard.sh" -o "$_pg_tmp/scripts/install-pii-push-guard.sh" \ && curl -fsSL "$REPO/scripts/git-hooks-portable/pre-push" -o "$_pg_tmp/scripts/git-hooks-portable/pre-push" \ && curl -fsSL "$REPO/scripts/git-hooks-portable/prepare-commit-msg" -o "$_pg_tmp/scripts/git-hooks-portable/prepare-commit-msg" \ && curl -fsSL "$REPO/scripts/pii-scan.sh" -o "$_pg_tmp/scripts/pii-scan.sh" \ && curl -fsSL "$REPO/.github/pii-denylist.txt" -o "$_pg_tmp/.github/pii-denylist.txt"; then rm -rf "$_pg_src" install -d -m 755 "$_pg_src" cp -a "$_pg_tmp/." "$_pg_src/" chmod 755 "$_pg_src/scripts/install-pii-push-guard.sh" "$_pg_src/scripts/git-hooks-portable/pre-push" "$_pg_src/scripts/git-hooks-portable/prepare-commit-msg" chmod 644 "$_pg_src/scripts/pii-scan.sh" "$_pg_src/.github/pii-denylist.txt" if _pg_out="$(PII_GUARD_SRC_SHA="${GH_PINNED_SHA:-}" "$_pg_src/scripts/install-pii-push-guard.sh" --sync 2>&1)"; then ok "pii-guard home — ${_pg_out#pii-push-guard: }" else echo "warn: pii-guard home NOT synced (${_pg_out:-no output}) — a portable install on this box would point at a scanner that is not there" >&2 fi else echo "warn: failed to stage the pii-guard payload from $REPO — guard home not refreshed this run; existing portable installs keep reading the copy they have" >&2 fi rm -rf "$_pg_tmp" # <<< DIVE-2803 guard-home staging # # One config per clone covers every linked worktree AND every future one, so # new landing boxes get the guard on install and existing ones on the daily # update cron. A box with no checkout is a clean no-op. # WHY here and not branch protection: our landing account is the repo admin, # so a required check only gates external PRs, never our own agent commits. for _cli_co in /home/*/projects/*/5dive-cli /home/*/projects/5dive/5dive-cli /root/5dive-cli; do [[ -e "$_cli_co/.git" ]] || continue case "$(git -C "$_cli_co" config --get remote.origin.url 2>/dev/null)" in *5dive-ai/5dive*) ;; *) continue ;; esac if [[ -x "$_cli_co/scripts/install-pii-push-guard.sh" ]]; then "$_cli_co/scripts/install-pii-push-guard.sh" "$_cli_co" >/dev/null 2>&1 && ok "pii-push-guard wired ($_cli_co)" else git -C "$_cli_co" config --local core.hooksPath scripts/git-hooks 2>/dev/null && ok "pii-push-guard wired ($_cli_co)" fi done } # --- Subcommand dispatch --------------------------------------------------- if [[ "${1:-}" == "--uninstall" ]]; then shift PURGE=0 YES=0 for a in "$@"; do case "$a" in --purge) PURGE=1 ;; --yes|-y) YES=1 ;; *) die "unknown uninstall flag: $a" ;; esac done say "Uninstalling 5dive CLI" # 1. Stop + remove any running agents — leaves /var/lib/5dive/agents.json # consistent so an --upgrade reinstall could restore the registry. With # --purge we wipe everything anyway. if command -v 5dive >/dev/null 2>&1; then if [[ -f "$STATE_DIR/agents.json" ]]; then mapfile -t AGENT_NAMES < <(jq -r '.agents | keys[]?' "$STATE_DIR/agents.json" 2>/dev/null || true) if [[ ${#AGENT_NAMES[@]} -gt 0 ]]; then say "Stopping ${#AGENT_NAMES[@]} agent(s)" if [[ $YES -eq 0 ]]; then printf " %s\n" "${AGENT_NAMES[@]}" read -r -p " remove these agents? [y/N] " ans [[ "$ans" =~ ^[yY] ]] || die "aborted" fi for n in "${AGENT_NAMES[@]}"; do 5dive agent rm "$n" >/dev/null 2>&1 || true ok "removed agent $n" done fi fi fi # 2. systemd units + reload if [[ -f "$SYSTEMD_DIR/5dive-hermes-perms.path" ]]; then systemctl disable --now 5dive-hermes-perms.path >/dev/null 2>&1 || true rm -f "$SYSTEMD_DIR/5dive-hermes-perms.path" "$SYSTEMD_DIR/5dive-hermes-perms.service" ok "removed hermes-perms units" fi if [[ -f "$SYSTEMD_DIR/5dive-agent@.service" ]]; then rm -f "$SYSTEMD_DIR/5dive-agent@.service" ok "removed systemd template" fi rm -f /etc/sudoers.d/5dive-agent-list systemctl daemon-reload || true # 3. Binaries + shared libs rm -f "$BIN_DIR/5dive" "$BIN_DIR/5dive-agent-start" "$BIN_DIR/5dive-agent-stop-notify" ok "removed CLI binaries" rm -f /etc/cron.d/5dive-disk # DIVE-5190: its target is the binary just removed if [[ -d "$LIB_DIR" ]]; then rm -rf "$LIB_DIR" ok "removed $LIB_DIR (hooks, skills, ui)" fi # 4. State / connector / claude user — keep by default; --purge wipes. if [[ $PURGE -eq 1 ]]; then if [[ $YES -eq 0 ]]; then echo echo " --purge will permanently delete:" [[ -d "$STATE_DIR" ]] && echo " $STATE_DIR (registry, auth profiles, audit log)" [[ -d "$CONNECTORS_DIR" ]] && echo " $CONNECTORS_DIR (telegram/discord bot tokens)" id -u claude >/dev/null 2>&1 && echo " user 'claude' and /home/claude" read -r -p " continue? [y/N] " ans [[ "$ans" =~ ^[yY] ]] || die "aborted" fi rm -rf "$STATE_DIR" "$CONNECTORS_DIR" ok "removed state + connector dirs" if id -u claude >/dev/null 2>&1; then userdel -r claude 2>/dev/null || userdel claude 2>/dev/null || true ok "removed user 'claude'" fi getent group claude >/dev/null 2>&1 && groupdel claude 2>/dev/null && ok "removed group 'claude'" || true else echo say "kept (run again with --purge to remove):" [[ -d "$STATE_DIR" ]] && echo " $STATE_DIR" [[ -d "$CONNECTORS_DIR" ]] && echo " $CONNECTORS_DIR" id -u claude >/dev/null 2>&1 && echo " user 'claude'" fi echo echo "5dive uninstalled." exit 0 fi if [[ "${1:-}" == "--upgrade" ]]; then shift # --no-ui used to gate the (since-removed) local dashboard install; the flag # was dropped in 8932961 but smoke harnesses and operator scripts still pass # it. Swallow it here as a deprecated no-op rather than break callers. [[ "${1:-}" == "--no-ui" ]] && shift [[ $# -eq 0 ]] || die "--upgrade takes no extra flags" [[ -x "$BIN_DIR/5dive" ]] || die "no existing 5dive at $BIN_DIR/5dive — run install without --upgrade first" # DIVE-1260: read the current version so we can report old -> new after the swap. _old_ver="$(grep -m1 'readonly FIVE_VERSION=' "$BIN_DIR/5dive" 2>/dev/null | sed -E 's/.*="([^"]+)".*/\1/')" say "Upgrading 5dive CLI (skipping apt / nvm / bun / state setup)" refresh_managed_files # >>> DIVE-6618 host plugins (extracted verbatim by tests/plugin_host_auto_upgrade_unit.sh) # Box-wide plugins (/var/lib/5dive/plugins) were install-once on a self-installed # box: nothing refreshed their marketplace or upgraded them, so a fix shipped in # voice never arrived. The freshly-installed CLI moves the official ones to what # their marketplace offers and names the community ones that are behind. Before # the seat refresh below, so seats are re-pinned at the new versions. Its own # exit and its own reason: a plugin that cannot move never fails the upgrade. # Read whole, never piped into `grep -q`: under pipefail an early-exiting grep # can SIGPIPE the CLI and read as "not supported". An older pinned CLI lacks # the verb, and then this step is skipped rather than failed. _hp_help="$("$BIN_DIR/5dive" plugin --help 2>/dev/null || true)" if [[ "$_hp_help" == *"plugin auto-upgrade"* ]]; then say "Upgrading box plugins" "$BIN_DIR/5dive" plugin auto-upgrade 2>&1 | sed 's/^/ /' \ || echo "warn: box plugins: some did not upgrade (the reason is on the lines above); the CLI upgrade went ahead" >&2 fi # <<< DIVE-6618 host plugins # Plugins are SHA-pinned per-user in installed_plugins.json, so CLI # upgrade alone doesn't refresh them. Run the helper best-effort: if # no agents are registered yet (fresh box) it's a no-op; if claude # is missing it self-skips. Failures here shouldn't block the upgrade. if [[ -x "$BIN_DIR/5dive-refresh-plugins.sh" ]]; then "$BIN_DIR/5dive-refresh-plugins.sh" 2>&1 | tail -20 || true fi # Backfill default skills onto existing agents, from the one list the create # path also seeds (DEFAULT_AGENT_SKILLS, DIVE-4203). # Same best-effort contract: no-op on a fresh box, self-skips never-booted # agents, failures don't block the upgrade. if [[ -x "$BIN_DIR/5dive-refresh-skills.sh" ]]; then "$BIN_DIR/5dive-refresh-skills.sh" 2>&1 | tail -20 || true fi # DIVE-758: turn gate-proof enforcement ON as boxes adopt the tamper-evidence # build. Once enforced, an UNPROVEN agent-path answer to an approval/secret gate # is rejected (human taps via --human always clear, the dashboard doesn't answer # gates), so "X approved gate Y" can't be self-cleared by an agent. Idempotent + # best-effort: never block an upgrade on it. "$BIN_DIR/5dive" gate-proof enforce on >/dev/null 2>&1 || true # DIVE-5372: route secrets.<domain> and let Caddy fetch its certificate now, so # a box's first one-time secret link opens on the first tap instead of failing # TLS while the certificate is still being issued. No-op without Caddy or # FIVE_DOMAIN, idempotent once routed. Best-effort: never block an upgrade on it. "$BIN_DIR/5dive" secret _prewarm >/dev/null 2>&1 || true # >>> DIVE-5247 partner plugin migration (extracted verbatim by tests/sysadmin_core_seams_unit.sh) # `5dive sysadmin` left core for the partner plugin. A box that already has the # sysadmin seat (a partner box built before the move) gets the plugin in the SAME # upgrade that removed the built-in. There is no earlier moment: while the # built-in exists the plugin's claim is refused (rc=3, "already a 5dive # command"), and without the plugin the seat's one grant, `5dive sysadmin # _broker`, reaches "unknown command". A box without the seat is untouched; a box # whose `sysadmin` already answers skips, so a failed add is retried next upgrade. # Best-effort: never block an upgrade on it. if jq -e '.agents.sysadmin != null' "$STATE_DIR/agents.json" >/dev/null 2>&1 \ && ! "$BIN_DIR/5dive" sysadmin --help >/dev/null 2>&1; then say "Installing the partner plugin (this box has the sysadmin seat)" "$BIN_DIR/5dive" plugin add "$GH_ORG/5dive-partner" --yes 2>&1 | tail -3 \ || echo "warn: partner plugin not installed — the sysadmin seat is down until the next upgrade retries" >&2 fi # <<< DIVE-5247 partner plugin migration echo # DIVE-1260: report the version actually swapped in, read from the new bundle. _new_ver="$(grep -m1 'readonly FIVE_VERSION=' "$BIN_DIR/5dive" 2>/dev/null | sed -E 's/.*="([^"]+)".*/\1/')" # >>> DIVE-2243 upgrade report (extracted verbatim by tests/install_monotonicity_unit.sh) # Report the direction the guard measured. A current-main bundle legitimately # says 0.0.0-dev, so comparing that sentinel after the swap would recreate the # false "DOWNGRADED" message DIVE-2603 removes. _old_id="${_old_ver:-unknown}${INSTALL_INSTALLED_SHA:+ at ${INSTALL_INSTALLED_SHA}}" _new_id="${_new_ver:-unknown}${INSTALL_CANDIDATE_SHA:+ at ${INSTALL_CANDIDATE_SHA}}" case "${INSTALL_DIRECTION:-unchecked}" in rollback) echo "5dive DOWNGRADED: ${_old_id} -> ${_new_id}" ;; same) echo "5dive refreshed: ${_old_id} -> ${_new_id} (build identity unchanged)" ;; forward) echo "5dive upgraded: ${_old_id} -> ${_new_id}" ;; *) echo "5dive updated: ${_old_id} -> ${_new_id} (direction unchecked)" ;; esac # <<< DIVE-2243 upgrade report exit 0 fi # --- Install (default) ----------------------------------------------------- say "Installing 5dive CLI" # System dependencies. Skip apt entirely if every package is already # installed — both speeds up reruns and avoids apt-lock contention when # unattended-upgrades is running concurrently (common on freshly-provisioned # boxes). say "Installing system dependencies" # acl: setfacl keeps `claude` reading the keys moved off group claude (DIVE-5690). APT_PKGS="jq tmux git curl python3-yaml unzip sqlite3 acl" apt_need=0 for p in $APT_PKGS; do dpkg -s "$p" >/dev/null 2>&1 || { apt_need=1; break; } done if (( apt_need )); then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq $APT_PKGS ok "$APT_PKGS" else ok "$APT_PKGS already present" fi # Create claude group + user (agents run as agent-<name> in the claude group) if ! getent group claude >/dev/null 2>&1; then groupadd --system claude ok "group 'claude' created" fi if ! id -u claude >/dev/null 2>&1; then useradd --system --gid claude --shell /bin/bash --create-home --home-dir /home/claude claude ok "user 'claude' created" fi # DIVE-3811: a clean install used to end its own doctor run on a red # `host/audit-drop-dir: /var/log/5dive/notify is missing`. The tree is otherwise # built lazily by audit_init, which only runs behind ensure_state — so on a box # where nothing root-side has touched the state tree yet, the doctor at the end # of `5dive init` is the FIRST thing to look, and it correctly reports a gap the # installer left. Create it here, where the `claude` group has just been # guaranteed to exist, so the shape audit_init promises is true from minute one. # Modes are audit_init's, verbatim: the parent stays 2750 so the tamper-evident # audit log is never group-writable, and only the purpose-built notify/ subdir is # 2770 (setgid + group write) so an agent-context drop marker can be written. mkdir -p /var/log/5dive/notify chown root:claude /var/log/5dive /var/log/5dive/notify chmod 2750 /var/log/5dive chmod 2770 /var/log/5dive/notify ok "audit log tree ready (/var/log/5dive, notify/ 2770)" # DIVE-3811: put ~/.local/bin on the `claude` service account's login PATH # BEFORE any runtime installer runs there. Upstream's Claude Code installer # checks the live PATH and, not finding it, prints a two-line # "run: echo 'export PATH=...' >> ~/.bashrc && source ~/.bashrc" advisory — # twice — into the middle of `5dive init`. That advice is unactionable for the # person reading it: the runtime lives under this service account, not the # operator's home, so the ~/.bashrc it names is not theirs, and the wizard # proceeds correctly either way. Making the statement FALSE removes the warning # at its source instead of filtering upstream's wording, and it is independently # correct: `sudo -u claude -i` shells are how this CLI reaches every runtime bin. if ! sudo -u claude grep -q '\.local/bin' /home/claude/.bash_profile 2>/dev/null; then sudo -u claude bash -c 'cat >> /home/claude/.bash_profile <<'"'"'LOCALBIN'"'"' # 5dive: agent runtimes (claude, devin, codex, pi, ...) install into ~/.local/bin export PATH="$HOME/.local/bin:$PATH" LOCALBIN ' ok "~/.local/bin on claude's login PATH" fi # nvm + node (needed for codex agent type) say "Installing nvm + Node.js" if [[ ! -f /home/claude/.nvm/nvm.sh ]]; then sudo -u claude bash -c 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | PROFILE=/dev/null bash' ok "nvm installed" fi # Write nvm init to .bash_profile so `bash -lc` commands (used by the CLI) find # node/npm. Guarded so reruns don't accumulate duplicate blocks. if ! sudo -u claude grep -q 'NVM_DIR="$HOME/.nvm"' /home/claude/.bash_profile 2>/dev/null; then sudo -u claude bash -c 'cat >> /home/claude/.bash_profile <<'"'"'NVM_INIT'"'"' export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" NVM_INIT ' fi sudo -u claude bash -lc "nvm install $NODE_VERSION && nvm alias default $NODE_VERSION" 2>&1 | grep -E "Downloading|Now using|default" || true ok "Node.js $NODE_VERSION" # bun (needed for telegram plugin) say "Installing bun" if ! sudo -u claude bash -lc 'command -v bun' >/dev/null 2>&1; then # DIVE-1263: install system-wide to /usr/local/bin (BUN_INSTALL=/usr/local), # matching ensure_bun_for_agent. The old default install dropped bun at # ~claude/.bun/bin/bun, which 5dive-agent-start's hardcode missed → pi/opencode # telegram bridges crash-looped on fresh boxes. Now every install path lands # bun where the runtime resolver looks first. curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash >/dev/null 2>&1 ok "bun installed (/usr/local/bin/bun)" else ok "bun already present" fi # Create directories say "Setting up 5dive directories" # setgid 2750: agent-<name> users (in the claude group) need to traverse the # tree to read their own *.env, but no one outside the group should see the # registry. setgid keeps the group on any file written here by the root-only # CLI (registry rewrites, per-agent envs). install -d -m 2750 "$STATE_DIR" install -d -m 2750 "$STATE_DIR/agents.d" install -d -m 750 "$CONNECTORS_DIR" chown root:claude "$STATE_DIR" "$STATE_DIR/agents.d" "$CONNECTORS_DIR" # Pre-create an empty registry so the first `5dive agent create` doesn't race # the lazy-init path. Mode 640 root:claude — readable by the group, only root # can write. if [[ ! -f "$STATE_DIR/agents.json" ]]; then echo '{"agents":{}}' > "$STATE_DIR/agents.json" chown root:claude "$STATE_DIR/agents.json" chmod 640 "$STATE_DIR/agents.json" fi ok "directories ready" # ── DIVE-4128: the per-box shared team wiki ───────────────────────────────── # Knowledge sharing between seats had NO on-box home. `5dive memory` resolved # its "shared wiki" to the product repo's community/wiki, a path that exists # only on our own fleet — so on a customer box every seat's atoms stayed # 0600-private, `memory add --store=wiki` refused, and # `agent create --inherit-memory=wiki` seeded 0 files while reporting success. # # 2775 root:claude, and each bit is load-bearing: # setgid (2) a page written by agent-alex keeps group `claude`, so agent-bo # can still edit it. Without it the group follows the writer's # primary group and the wiki de-shares itself one page at a time. # g=rwx every seat PUBLISHES, not just reads. A read-only shared wiki # is a broadcast channel, not a team wiki. # o=rx readable by seats outside the claude group — `sandboxed` # agents are deliberately not in it (DIVE-1033) and would # otherwise boot unable to read team knowledge. Team knowledge is # not a secret; credentials live behind their own 0600 elsewhere. # # NOT provisioned on a box that already has the product repo checked out: there # the wiki IS community/wiki, git-tracked and reviewed, and minting a second # untracked root would silently split the fleet's wiki in two the moment this # upgrade landed. The resolver prefers the box root when it exists, so its # ABSENCE here is what keeps the fleet publishing into git. if [[ -d /home/claude/projects/5dive/community/wiki ]]; then ok "shared wiki: using the product repo's community/wiki (fleet box)" else install -d -m 2775 -o root -g claude "$STATE_DIR/wiki" chmod 2775 "$STATE_DIR/wiki" # install -m does not always set setgid # An index the first publisher can append to. `memory add` deliberately never # invents an index file (it will not fabricate a store's table of contents), # so without this seed the first page on a fresh box is written and then # never listed — present, unfindable by anyone browsing. if [[ ! -f "$STATE_DIR/wiki/index.md" ]]; then printf '# Team wiki\n\nShared, searchable knowledge for every agent on this box.\nPublish with: 5dive memory add --store=wiki --name=<slug> --desc=<one line>\nRead with: 5dive memory search --store=wiki "<topic>"\n\n' \ > "$STATE_DIR/wiki/index.md" chown root:claude "$STATE_DIR/wiki/index.md" chmod 664 "$STATE_DIR/wiki/index.md" fi ok "shared wiki ready at $STATE_DIR/wiki (writable by every seat)" fi # Install / refresh CLI binaries, systemd unit, hooks, and skills. # preseed_claude_agent references the hooks by absolute path under # /usr/local/lib/5dive/ and warns at agent-create time if any are missing — # without them the channel-paired agent will appear to start fine but its # rate-limit handler / picker-blocking guard / missed-reply auto-relay are # all silently disabled. say "Installing CLI binaries, systemd unit, hooks, and skills" refresh_managed_files echo echo "5dive installed successfully." echo # Show health state immediately so a fresh user knows whether anything is # missing (e.g. agent type binaries) before they try to create an agent. # Fail-soft: doctor itself always exits 0, but `|| true` guards against # future regressions so a doctor crash never breaks the install. say "Running health check" 5dive doctor || true # DIVE-758: secure-by-default — new boxes get gate-proof enforcement ON (same as # the --upgrade path flips it for existing boxes). Best-effort; never block install. 5dive gate-proof enforce on >/dev/null 2>&1 || true # DIVE-5372: same pre-warm as the --upgrade path — the secrets.<domain> route # and its certificate exist before the first secret gate needs a link. 5dive secret _prewarm >/dev/null 2>&1 || true echo echo "Next steps:" echo " 5dive agent list # list agents" echo " 5dive doctor --repair # auto-install agent type binaries" echo " 5dive agent create my-agent --type=claude # create your first agent" echo echo "To upgrade later: sudo 5dive self-update" echo "Managed hosts already update nightly. Self-hosted opt-in (root crontab):" echo " 0 4 * * * /usr/local/bin/5dive self-update >> /var/log/5dive-self-update.log 2>&1" echo "Fallback: curl -fsSL $REPO/install.sh | sudo bash -s -- --upgrade" echo "Full CLI docs: https://5dive.ai/docs/5dive-cli" echo "Source: https://github.com/$GH_ORG/5dive"