#!/usr/bin/env bash
# =====================================================================
# corypt.sh — the Corypt agent. One file: installer, service and tools.
# CORYPT_AGENT_SCRIPT  (marker used to recognise this file; keep it)
#
# First time, as root:
#     ./corypt.sh install CODE        (CODE comes from corypt.com, Servers)
#
# After that the agent lives at /usr/local/bin/corypt:
#     corypt status       is it connected?
#     corypt logs [-f]    what is it doing?
#     corypt restart      restart the service
#     corypt update       get the newest version
#     corypt uninstall    remove everything from this server
#
# File tools (used by AI agents to edit precisely):
#     corypt read  FILE [START] [END]
#     corypt write FILE START END       replacement on stdin
#     corypt grep  PATTERN FILE [CONTEXT]
#     corypt lines FILE
#
# Needs only bash and curl. jq is installed automatically if missing.
# =====================================================================
set -o pipefail

CORYPT_VERSION="1.7.0"
DEFAULT_API="https://build-mcp.corypt.com"

CONF_DIR="/etc/corypt"
CONF_FILE="$CONF_DIR/agent.conf"
BIN_PATH="/usr/local/bin/corypt"
LIB_DIR="/usr/local/lib/corypt"
STATE_DIR="/var/lib/corypt"
JOBS_DIR="$STATE_DIR/jobs"
REMOVED_FLAG="$STATE_DIR/removed"
LOG_FILE="/var/log/corypt.log"
PID_FILE="/run/corypt.pid"
LOCK_FILE="/run/corypt.lock"
UNIT_FILE="/etc/systemd/system/corypt.service"
OPENRC_FILE="/etc/init.d/corypt"
CRON_FILE="/etc/cron.d/corypt"

MAX_JOBS=8               # jobs running at the same time
OUT_LIMIT=262144         # bytes kept per output stream
OUT_HEAD=32768           # of those, bytes kept from the start
PROGRESS_EVERY=10        # seconds between progress reports
PROGRESS_TAIL=20000      # bytes of output sent in a progress report
HELLO_EVERY=600          # seconds between system-info reports

SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || printf '%s' "${BASH_SOURCE[0]:-$0}")"

# ---------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------

say()  { printf '%s\n' "$*"; }
step() { printf '  \342\200\242 %s\n' "$*"; }
good() { printf '  \342\234\223 %s\n' "$*"; }
die()  { printf 'corypt: %s\n' "$*" >&2; exit 1; }
log()  { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }

now_ms() {
  # Some date implementations (e.g. the Rust coreutils in Ubuntu 26.04) ignore
  # the "3" in %3N and print all 9 digits, so take nanoseconds and divide.
  local t
  t="$(date +%s%N 2>/dev/null)"
  case "$t" in
    ''|*[!0-9]*) printf '%s' "$(( $(date +%s) * 1000 ))" ;;
    *) if [ "${#t}" -ge 16 ]; then printf '%s' "$(( t / 1000000 ))"; else printf '%s' "$(( $(date +%s) * 1000 ))"; fi ;;
  esac
}

file_size() { local s; s="$(wc -c <"$1" 2>/dev/null | tr -d ' ')"; printf '%s' "${s:-0}"; }

# ---------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------

JQ="jq"

pkg_install() {
  if command -v apt-get >/dev/null 2>&1; then
    DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null 2>&1
  elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" >/dev/null 2>&1
  elif command -v yum >/dev/null 2>&1; then yum install -y -q "$@" >/dev/null 2>&1
  elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive --quiet install "$@" >/dev/null 2>&1
  elif command -v apk >/dev/null 2>&1; then apk add --no-cache "$@" >/dev/null 2>&1
  elif command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm --needed "$@" >/dev/null 2>&1
  else return 1
  fi
}

# jq must support --rawfile (jq 1.6+). Old distros ship 1.5, so we keep
# our own static copy in that case.
jq_ok() { "$1" -n --rawfile x /dev/null '1' >/dev/null 2>&1; }

install_static_jq() {
  local arch
  case "$(uname -m)" in
    x86_64|amd64) arch=amd64 ;;
    aarch64|arm64) arch=arm64 ;;
    armv7l|armv7|armhf) arch=armhf ;;
    i386|i686) arch=i386 ;;
    *) return 1 ;;
  esac
  mkdir -p "$LIB_DIR"
  curl -fsSL --max-time 120 "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-$arch" -o "$LIB_DIR/jq.tmp" || return 1
  chmod 755 "$LIB_DIR/jq.tmp" && mv "$LIB_DIR/jq.tmp" "$LIB_DIR/jq"
  jq_ok "$LIB_DIR/jq"
}

pick_jq() {
  if [ -x "$LIB_DIR/jq" ] && jq_ok "$LIB_DIR/jq"; then JQ="$LIB_DIR/jq"; return 0; fi
  if command -v jq >/dev/null 2>&1 && jq_ok jq; then JQ="jq"; return 0; fi
  return 1
}

ensure_deps() {
  local need=()
  command -v curl >/dev/null 2>&1 || need+=(curl)
  command -v jq >/dev/null 2>&1 || need+=(jq)
  if [ ${#need[@]} -gt 0 ]; then
    step "installing ${need[*]}"
    pkg_install "${need[@]}" ca-certificates || true
  fi
  command -v curl >/dev/null 2>&1 || die "curl is missing and could not be installed. Install curl and run this again."
  if ! pick_jq; then
    step "installing a recent jq"
    install_static_jq || die "jq 1.6 or newer is needed and could not be installed. Install jq and run this again."
    pick_jq || die "jq could not be set up."
  fi
}

# ---------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------

API=""; SERVER_ID=""; SECRET=""; NICKNAME=""; ACCOUNT=""; POLL_TIMEOUT=25

load_conf() {
  [ -r "$CONF_FILE" ] || return 1
  # shellcheck disable=SC1090
  . "$CONF_FILE"
  [ -n "${SECRET:-}" ] && [ -n "${API:-}" ]
}

save_conf() {
  mkdir -p "$CONF_DIR" && chmod 700 "$CONF_DIR"
  ( umask 077
    {
      printf '# Corypt agent configuration. Keep this file private.\n'
      printf 'API=%q\n' "$API"
      printf 'SERVER_ID=%q\n' "$SERVER_ID"
      printf 'SECRET=%q\n' "$SECRET"
      printf 'NICKNAME=%q\n' "$NICKNAME"
      printf 'ACCOUNT=%q\n' "$ACCOUNT"
      printf 'POLL_TIMEOUT=%q\n' "$POLL_TIMEOUT"
    } >"$CONF_FILE" )
  chmod 600 "$CONF_FILE"
}

# ---------------------------------------------------------------------
# Talking to Corypt
# ---------------------------------------------------------------------

sysinfo_json() {
  local os=""
  if [ -r /etc/os-release ]; then os="$( . /etc/os-release; printf '%s' "${PRETTY_NAME:-${NAME:-}}" )"; fi
  [ -n "$os" ] || os="$(uname -s)"
  "$JQ" -n --arg hostname "$(uname -n)" --arg os "$os" --arg arch "$(uname -m)" \
        --arg kernel "$(uname -r)" --arg agent_version "$CORYPT_VERSION" \
        '{hostname:$hostname, os:$os, arch:$arch, kernel:$kernel, agent_version:$agent_version}'
}

# api METHOD PATH [BODY_FILE]  -> body on stdout, status in $HTTP
HTTP=""
api() {
  local m="$1" p="$2" f="${3:-}" out
  out="$(mktemp)"
  local args=(-sS -o "$out" -w '%{http_code}' --max-time 30 -X "$m" -H "authorization: Bearer $SECRET")
  [ -n "$f" ] && args+=(-H 'content-type: application/json' --data-binary "@$f")
  HTTP="$(curl "${args[@]}" "$API$p" 2>/dev/null)" || HTTP="000"
  cat "$out"
  rm -f "$out"
}

do_hello() {
  local f
  f="$(mktemp)"
  sysinfo_json >"$f"
  api POST /agent/hello "$f" >/dev/null
  rm -f "$f"
}

# ---------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------

install_binary() {
  mkdir -p "$(dirname "$BIN_PATH")"
  if [ -f "$SELF" ] && grep -q 'CORYPT_AGENT_SCRIPT' "$SELF" 2>/dev/null; then
    if [ "$SELF" != "$BIN_PATH" ]; then
      cp "$SELF" "$BIN_PATH.tmp" && chmod 755 "$BIN_PATH.tmp" && mv "$BIN_PATH.tmp" "$BIN_PATH"
    fi
  else
    curl -fsSL --max-time 60 "$API/corypt.sh" -o "$BIN_PATH.tmp" || die "could not download the agent from $API/corypt.sh"
    grep -q 'CORYPT_AGENT_SCRIPT' "$BIN_PATH.tmp" || die "the downloaded file is not the Corypt agent"
    chmod 755 "$BIN_PATH.tmp" && mv "$BIN_PATH.tmp" "$BIN_PATH"
  fi
}

service_kind() {
  if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then printf systemd
  elif command -v rc-service >/dev/null 2>&1 && [ -d /etc/init.d ]; then printf openrc
  else printf background
  fi
}

install_service() {
  mkdir -p "$STATE_DIR" "$JOBS_DIR"
  rm -f "$REMOVED_FLAG"
  case "$(service_kind)" in
    systemd)
      cat >"$UNIT_FILE" <<'UNIT'
[Unit]
Description=Corypt agent (connects this server to corypt.com)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/corypt run
Restart=on-failure
RestartSec=5
# Exit code 3 means "this server's secret was rejected": do not loop.
RestartPreventExitStatus=3
# Only the agent itself is stopped on restart; commands it started keep
# running and still report their results.
KillMode=process
TimeoutStopSec=15
Environment=HOME=/root

[Install]
WantedBy=multi-user.target
UNIT
      systemctl daemon-reload
      systemctl enable corypt >/dev/null 2>&1
      systemctl restart corypt
      ;;
    openrc)
      cat >"$OPENRC_FILE" <<'RC'
#!/sbin/openrc-run
name="corypt"
description="Corypt agent"
command="/usr/local/bin/corypt"
command_args="run"
command_background=true
pidfile="/run/corypt-openrc.pid"
output_log="/var/log/corypt.log"
error_log="/var/log/corypt.log"
depend() { need net; }
RC
      chmod 755 "$OPENRC_FILE"
      rc-update add corypt default >/dev/null 2>&1
      rc-service corypt restart >/dev/null 2>&1 || rc-service corypt start >/dev/null 2>&1
      ;;
    background)
      # No service manager: start now, and let cron bring it back after a
      # reboot or a crash.
      if [ -d /etc/cron.d ]; then
        printf '%s\n' "@reboot root $BIN_PATH start-bg" "*/5 * * * * root $BIN_PATH start-bg" >"$CRON_FILE"
        chmod 644 "$CRON_FILE"
      elif command -v crontab >/dev/null 2>&1; then
        ( crontab -l 2>/dev/null | grep -v 'corypt start-bg'
          printf '%s\n' "@reboot $BIN_PATH start-bg" "*/5 * * * * $BIN_PATH start-bg" ) | crontab -
      fi
      "$BIN_PATH" stop-bg >/dev/null 2>&1
      "$BIN_PATH" start-bg
      ;;
  esac
}

agent_running() {
  local pid
  pid="$(cat "$PID_FILE" 2>/dev/null)"
  [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}

cmd_install() {
  local code
  code="$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]' | tr -d ' ')"
  say ""
  say "Corypt agent $CORYPT_VERSION"
  say ""
  ensure_deps
  good "bash, curl and jq are ready"

  if load_conf; then
    do_hello
    if [ "$HTTP" = "200" ]; then
      good "this server is already connected as \"$NICKNAME\" ($ACCOUNT)"
      install_binary
      install_service
      good "agent and service refreshed"
      say ""
      say "To connect it to a different account, run:  corypt uninstall   then the new install command."
      return 0
    fi
    step "the old connection is no longer valid (HTTP $HTTP), connecting again"
  fi

  [ -n "$code" ] || die "missing code. Usage:  ./corypt.sh install CODE   (get a code at https://corypt.com/app#servers)"
  printf '%s' "$code" | grep -Eq '^[A-Z0-9]{8}$' || die "\"$code\" is not a valid code. Codes are 8 letters and digits."

  API="${CORYPT_API:-$DEFAULT_API}"
  API="${API%/}"
  step "connecting to $API"
  local body out resp
  body="$(mktemp)"; out="$(mktemp)"
  sysinfo_json | "$JQ" --arg code "$code" '. + {code:$code}' >"$body"
  HTTP="$(curl -sS -o "$out" -w '%{http_code}' --max-time 30 -H 'content-type: application/json' --data-binary "@$body" "$API/agent/enroll" 2>/dev/null)" || HTTP="000"
  resp="$(cat "$out")"; rm -f "$body" "$out"
  if [ "$HTTP" != "200" ]; then
    local msg
    msg="$(printf '%s' "$resp" | "$JQ" -r '.message // .error // empty' 2>/dev/null)"
    die "could not connect (HTTP $HTTP). ${msg:-Check that this server can reach $API.}"
  fi

  SERVER_ID="$(printf '%s' "$resp" | "$JQ" -r '.server_id')"
  SECRET="$(printf '%s' "$resp" | "$JQ" -r '.secret')"
  NICKNAME="$(printf '%s' "$resp" | "$JQ" -r '.nickname')"
  ACCOUNT="$(printf '%s' "$resp" | "$JQ" -r '.account')"
  POLL_TIMEOUT="$(printf '%s' "$resp" | "$JQ" -r '.poll_timeout_seconds // 25')"
  save_conf
  good "connected to $ACCOUNT as \"$NICKNAME\""

  install_binary
  good "agent installed at $BIN_PATH"
  install_service
  good "service started ($(service_kind)) and set to start on every boot"

  # Give the service a moment, then confirm it is really talking to Corypt.
  local i ok=0
  for i in 1 2 3 4 5 6 7 8 9 10; do
    sleep 1
    if [ "$(service_kind)" = systemd ]; then systemctl is-active --quiet corypt && ok=1; else agent_running && ok=1; fi
    [ "$ok" = 1 ] && break
  done
  [ "$ok" = 1 ] || die "the service did not start. See: corypt logs"

  say ""
  say "Done. This server is now \"$NICKNAME\" on corypt.com."
  say "Rename it any time at https://corypt.com/app#servers"
  say "Check it with:  corypt status"
  say ""
}

# ---------------------------------------------------------------------
# The service loop
# ---------------------------------------------------------------------

TIMEOUT_CMD=()
pick_timeout() {
  if timeout --version >/dev/null 2>&1; then TIMEOUT_CMD=(timeout --kill-after=10)
  elif command -v timeout >/dev/null 2>&1; then TIMEOUT_CMD=(timeout)
  else TIMEOUT_CMD=()
  fi
}

# Keep the start and the end of very large output, cut the middle.
trim_output() {
  local f="$1" size
  size="$(file_size "$f")"
  if [ "${size:-0}" -gt "$OUT_LIMIT" ]; then
    { head -c "$OUT_HEAD" "$f"
      printf '\n\n[... corypt cut %s bytes from the middle of this output ...]\n\n' "$(( size - OUT_LIMIT ))"
      tail -c "$(( OUT_LIMIT - OUT_HEAD ))" "$f"
    } >"$f.trim" && mv "$f.trim" "$f"
    return 0
  fi
  return 1
}

post_file() { # PATH FILE -> retries; returns 0 on 2xx or 404
  local p="$1" f="$2" attempt code
  for attempt in 1 2 3 4 5 6 7 8; do
    code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 60 -H "authorization: Bearer $SECRET" \
            -H 'content-type: application/json' --data-binary "@$f" "$API$p" 2>/dev/null)" || code="000"
    case "$code" in 2*|404|410) return 0 ;; esac
    sleep $(( attempt * 3 ))
  done
  return 1
}

# ---- one job: EXEC -------------------------------------------------

job_exec() {
  local job="$1" dir="$2" id="$3" cwd timeout start pid mon ec dur last=-1 size timed_out=false truncated=false err=""
  printf '%s' "$job" | "$JQ" -r '.payload.command // ""' >"$dir/cmd.sh"
  cwd="$(printf '%s' "$job" | "$JQ" -r '.payload.cwd // ""')"
  timeout="$(printf '%s' "$job" | "$JQ" -r '.payload.timeout_seconds // 900')"
  if printf '%s' "$job" | "$JQ" -e '.payload.stdin != null' >/dev/null 2>&1; then
    printf '%s' "$job" | "$JQ" -j '.payload.stdin' >"$dir/stdin"
  else
    : >"$dir/stdin"
  fi
  [ -n "$cwd" ] || cwd="/root"
  : >"$dir/out"; : >"$dir/err"

  log "job $id started: $(head -c 100 "$dir/cmd.sh" | tr '\n' ' ')"
  if printf '%s' "$job" | "$JQ" -e '(.payload.env // {}) | length > 0' >/dev/null 2>&1; then
    log "job $id gets stored keys: $(printf '%s' "$job" | "$JQ" -r '.payload.env | keys | join(", ")')"   # names only
  fi
  start="$(now_ms)"

  if [ ! -d "$cwd" ]; then
    ec=1
    err="The directory $cwd does not exist on this server."
  else
    (
      cd "$cwd" || exit 1
      export HOME=/root DEBIAN_FRONTEND=noninteractive TERM=dumb PAGER=cat GIT_PAGER=cat SYSTEMD_PAGER="" CORYPT_JOB_ID="$id"
      export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
      # CORYPT_KEYS: stored keys Corypt sent with this job, for this command only. Read through a pipe,
      # never written to disk or to the log.
      while IFS=' ' read -r k v; do
        case "$k" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac
        export "$k=$(printf '%s' "$v" | base64 -d 2>/dev/null)"
      done < <(printf '%s' "$job" | "$JQ" -r '(.payload.env // {}) | to_entries[] | .key + " " + (.value | tostring | @base64)' 2>/dev/null)
      if [ ${#TIMEOUT_CMD[@]} -gt 0 ]; then
        exec "${TIMEOUT_CMD[@]}" "$timeout" bash "$dir/cmd.sh"
      else
        exec bash "$dir/cmd.sh"
      fi
    ) <"$dir/stdin" >"$dir/out" 2>"$dir/err" &
    pid=$!

    # A side loop sends the latest output every PROGRESS_EVERY seconds,
    # so the main path can simply wait and return the moment the command ends.
    (
      while sleep "$PROGRESS_EVERY"; do
        kill -0 "$pid" 2>/dev/null || exit 0
        size=$(( $(file_size "$dir/out") + $(file_size "$dir/err") ))
        [ "$size" = "$last" ] && continue
        last="$size"
        tail -c "$PROGRESS_TAIL" "$dir/out" >"$dir/p.out" 2>/dev/null
        tail -c "$PROGRESS_TAIL" "$dir/err" >"$dir/p.err" 2>/dev/null
        "$JQ" -n --arg job_id "$id" --rawfile stdout "$dir/p.out" --rawfile stderr "$dir/p.err" \
          '{job_id:$job_id, stdout:$stdout, stderr:$stderr}' >"$dir/p.json" 2>/dev/null \
          && curl -sS -o /dev/null --max-time 20 -H "authorization: Bearer $SECRET" -H 'content-type: application/json' \
                  --data-binary "@$dir/p.json" "$API/agent/progress" 2>/dev/null
      done
    ) &
    mon=$!
    wait "$pid"; ec=$?
    kill "$mon" 2>/dev/null; wait "$mon" 2>/dev/null
  fi

  dur=$(( $(now_ms) - start ))
  if [ "$ec" = 124 ] || { [ "$ec" = 137 ] && [ "$dur" -ge $(( timeout * 1000 )) ]; }; then timed_out=true; fi
  trim_output "$dir/out" && truncated=true
  trim_output "$dir/err" && truncated=true

  "$JQ" -n --arg job_id "$id" --argjson exit_code "$ec" --rawfile stdout "$dir/out" --rawfile stderr "$dir/err" \
        --argjson duration_ms "$dur" --argjson truncated "$truncated" --argjson timed_out "$timed_out" --arg error "$err" \
        '{job_id:$job_id, exit_code:$exit_code, stdout:$stdout, stderr:$stderr, duration_ms:$duration_ms,
          truncated:$truncated, timed_out:$timed_out}
         + (if $error != "" then {status:"error", error:$error} else {} end)' >"$dir/result.json"

  if post_file /agent/result "$dir/result.json"; then
    log "job $id finished: exit $ec in ${dur} ms"
    rm -rf "$dir"
  else
    log "job $id finished but its result could not be delivered; kept in $dir"
  fi
}

# ---- one job: NATIVE (file and project-tool operations) ------------
# Native jobs never touch a shell for their arguments, so a path or a
# field can never be read as a command. Each builds a result of the shape
#   {job_id, status, kind, stdout, stderr, exit_code, data}
# where "data" is structured and "stdout" is human/agent-readable text.

job_native() {
  local job="$1" dir="$2" id="$3" kind="$4"
  local status="done" exit_code=0 data="null"
  local out="$dir/out" err="$dir/err"
  : >"$out"; : >"$err"
  log "job $id ($kind) started"

  case "$kind" in
    file_read)
      local path start end
      path="$(printf '%s' "$job" | "$JQ" -r '.payload.path // ""')"
      start="$(printf '%s' "$job" | "$JQ" -r '.payload.start // empty')"
      end="$(printf '%s' "$job" | "$JQ" -r '.payload.end // empty')"
      ( cmd_read "$path" "$start" "$end" ) >"$out" 2>"$err" || { status=error; exit_code=1; }
      ;;
    file_grep)
      local pattern path context
      pattern="$(printf '%s' "$job" | "$JQ" -r '.payload.pattern // ""')"
      path="$(printf '%s' "$job" | "$JQ" -r '.payload.path // ""')"
      context="$(printf '%s' "$job" | "$JQ" -r '.payload.context // 0')"
      ( cmd_grep "$pattern" "$path" "$context" ) >"$out" 2>"$err" || { status=error; exit_code=1; }
      ;;
    file_list)
      local path depth
      path="$(printf '%s' "$job" | "$JQ" -r '.payload.path // ""')"
      depth="$(printf '%s' "$job" | "$JQ" -r '.payload.depth // 2')"
      ( nf_list "$path" "$depth" ) >"$out" 2>"$err" || { status=error; exit_code=1; }
      ;;
    file_write)
      local path existed=true bytes lines
      path="$(printf '%s' "$job" | "$JQ" -r '.payload.path // ""')"
      printf '%s' "$job" | "$JQ" -j '.payload.content // ""' >"$dir/content"
      if [ -z "$path" ]; then status=error; printf 'file_write: path is required' >"$err"
      elif printf '%s' "$path" | grep -q '^/' ; then
        [ -e "$path" ] || existed=false
        if mkdir -p "$(dirname "$path")" 2>"$err" && cat "$dir/content" >"$path" 2>>"$err"; then
          bytes="$(file_size "$path")"; lines="$(count_lines "$path")"
          data="$("$JQ" -n --arg p "$path" --argjson b "$bytes" --argjson l "$lines" --argjson ex "$existed" \
                  '{path:$p, bytes:$b, lines:$l, created:($ex|not)}')"
          "$JQ" -rn --arg p "$path" --argjson b "$bytes" --argjson l "$lines" \
                '"wrote \($p) (\($b) bytes, \($l) lines)"' >"$out"
        else status=error; exit_code=1; fi
      else status=error; printf 'file_write: path must be absolute (start with /)' >"$err"; fi
      ;;
    file_edit)
      local path start end
      path="$(printf '%s' "$job" | "$JQ" -r '.payload.path // ""')"
      start="$(printf '%s' "$job" | "$JQ" -r '.payload.start // empty')"
      end="$(printf '%s' "$job" | "$JQ" -r '.payload.end // empty')"
      printf '%s' "$job" | "$JQ" -j '.payload.replacement // ""' >"$dir/replacement"
      ( nf_edit "$path" "$start" "$end" "$dir/replacement" "$dir/data.json" ) >"$out" 2>"$err" || { status=error; exit_code=1; }
      [ "$status" = "done" ] && [ -s "$dir/data.json" ] && data="$(cat "$dir/data.json")"
      ;;
    manifest_read)
      local project mf
      project="$(printf '%s' "$job" | "$JQ" -r '.payload.project_path // ""')"
      mf="$project/.corypt/mcp/manifest.json"
      if [ -n "$project" ] && [ -f "$mf" ]; then
        cat "$mf" >"$out"
        data="$("$JQ" -n --arg p "$mf" '{exists:true, path:$p}')"
      else
        data="$("$JQ" -n --arg p "$mf" '{exists:false, path:$p}')"
        printf 'no manifest at %s' "$mf" >"$err"
      fi
      ;;
    tool_run)
      local project run_rel toutsecs full ec2
      project="$(printf '%s' "$job" | "$JQ" -r '.payload.project_path // ""')"
      run_rel="$(printf '%s' "$job" | "$JQ" -r '.payload.run // ""')"
      toutsecs="$(printf '%s' "$job" | "$JQ" -r '.payload.timeout_seconds // 30')"
      printf '%s' "$job" | "$JQ" -c '.payload.input // {}' >"$dir/input"
      if [ -z "$project" ] || [ -z "$run_rel" ]; then
        status=error; printf 'tool_run: project_path and run are required' >"$err"
      elif printf '%s' "$run_rel" | grep -q '\.\.'; then
        status=error; printf 'tool_run: run path may not contain ".."' >"$err"
      elif [ ! -d "$project" ]; then
        status=error; printf 'tool_run: project not found: %s' "$project" >"$err"
      else
        full="$project/$run_rel"
        if [ ! -f "$full" ]; then
          status=error; printf 'tool_run: tool script not found: %s' "$run_rel" >"$err"
        else
          local runner
          case "$full" in
            *.py) runner=(python3 "$full") ;;
            *.js|*.mjs|*.cjs) runner=(node "$full") ;;
            *.rb) runner=(ruby "$full") ;;
            *.php) runner=(php "$full") ;;
            *) if [ -x "$full" ]; then runner=("$full"); else runner=(bash "$full"); fi ;;
          esac
          (
            cd "$project" || exit 1
            export HOME=/root DEBIAN_FRONTEND=noninteractive TERM=dumb PAGER=cat CORYPT_PROJECT="$project"
            export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
            if [ ${#TIMEOUT_CMD[@]} -gt 0 ]; then exec "${TIMEOUT_CMD[@]}" "$toutsecs" "${runner[@]}"
            else exec "${runner[@]}"; fi
          ) <"$dir/input" >"$out" 2>"$err"
          ec2=$?
          exit_code=$ec2
          [ "$ec2" -eq 0 ] || status=error
          if [ "$ec2" = 124 ] || [ "$ec2" = 137 ]; then printf '\n[tool_run: timed out after %ss]' "$toutsecs" >>"$err"; fi
        fi
      fi
      ;;
    project_inspect|project_adopt|net|service_up|service_down|probe_get|vfile_read|vfile_write|vfile_edit|trail_append|trail_read|trail_state|trail_bundle)
      ( ext_native "$job" "$kind" "$dir/data.json" ) >"$out" 2>"$err" || { status=error; exit_code=1; }
      if [ -s "$dir/data.json" ] && "$JQ" -e . "$dir/data.json" >/dev/null 2>&1; then data="$("$JQ" -c . "$dir/data.json")"; fi
      ;;
    *)
      status=error; printf 'unknown job kind: %s' "$kind" >"$err"
      ;;
  esac

  trim_output "$out" >/dev/null 2>&1 || true
  trim_output "$err" >/dev/null 2>&1 || true

  "$JQ" -n --arg job_id "$id" --arg status "$status" --arg kind "$kind" \
        --rawfile stdout "$out" --rawfile stderr "$err" --argjson exit_code "$exit_code" --argjson data "$data" \
        '{job_id:$job_id, status:$status, kind:$kind, stdout:$stdout, stderr:$stderr, exit_code:$exit_code, data:$data}' \
        >"$dir/result.json"

  if post_file /agent/result "$dir/result.json"; then
    log "job $id ($kind) finished: $status"
    rm -rf "$dir"
  else
    log "job $id ($kind) finished but its result could not be delivered; kept in $dir"
  fi
}

run_job() {
  local job="$1" id kind dir
  id="$(printf '%s' "$job" | "$JQ" -r '.id')"
  kind="$(printf '%s' "$job" | "$JQ" -r '.kind // "exec"')"
  dir="$JOBS_DIR/$id"
  mkdir -p "$dir"
  case "$kind" in
    exec) job_exec "$job" "$dir" "$id" ;;
    *)    job_native "$job" "$dir" "$id" "$kind" ;;
  esac
}

running_jobs() { jobs -rp | wc -l | tr -d ' '; }

cmd_run() {
  load_conf || { log "not configured. Run: corypt install CODE"; exit 3; }
  ensure_deps >/dev/null
  pick_timeout
  mkdir -p "$STATE_DIR" "$JOBS_DIR"
  if [ -f "$REMOVED_FLAG" ]; then
    log "this server was removed from Corypt. Run the install command again to reconnect."
    exit 0
  fi

  # One agent at a time.
  if command -v flock >/dev/null 2>&1; then
    exec 9>"$LOCK_FILE"
    # Wait for a previous agent that is still shutting down.
    flock -w 30 9 || { log "another corypt agent is still running"; exit 1; }
  elif agent_running; then
    log "another corypt agent is already running"; exit 0
  fi
  printf '%s' "$$" >"$PID_FILE"
  POLL_PID=""
  trap 'log "stopping"; [ -n "$POLL_PID" ] && kill "$POLL_PID" 2>/dev/null; rm -f "$PID_FILE"; exit 0' TERM INT

  log "corypt $CORYPT_VERSION started as \"$NICKNAME\" ($ACCOUNT), talking to $API"
  local backoff=2 last_hello=0 resp codef n i t acked job
  resp="$(mktemp)"; codef="$(mktemp)"
  while :; do
    t="$(date +%s)"
    if [ $(( t - last_hello )) -ge "$HELLO_EVERY" ]; then
      do_hello
      case "$HTTP" in
        200) last_hello="$t" ;;
        410) log "this server was removed from Corypt; stopping. Run 'corypt uninstall' to clean up."; touch "$REMOVED_FLAG"; exit 0 ;;
        401) log "Corypt rejected this server's secret. Run the install command again with a new code."; exit 3 ;;
      esac
    fi

    while [ "$(running_jobs)" -ge "$MAX_JOBS" ]; do wait -n 2>/dev/null || sleep 1; done

    # The poll runs in the background and we wait for it: bash handles a stop
    # signal during 'wait' at once, but not while a foreground command runs.
    # 9>&- keeps the single-instance lock out of every child process.
    curl -sS -o "$resp" -w '%{http_code}' --max-time $(( POLL_TIMEOUT + 20 )) \
         -H "authorization: Bearer $SECRET" "$API/agent/poll" >"$codef" 2>/dev/null 9>&- &
    POLL_PID=$!
    wait "$POLL_PID"
    POLL_PID=""
    HTTP="$(cat "$codef" 2>/dev/null)"; [ -n "$HTTP" ] || HTTP="000"
    case "$HTTP" in
      200)
        backoff=2
        n="$("$JQ" '.jobs | length' "$resp" 2>/dev/null || printf 0)"
        if [ "${n:-0}" -gt 0 ] 2>/dev/null; then
          # Confirm receipt first. Only confirmed jobs run, so a job is never
          # run twice; unconfirmed ones go back to the queue on Corypt's side.
          "$JQ" '{job_ids: [.jobs[].id]}' "$resp" >"$codef.ack"
          acked="$(api POST /agent/ack "$codef.ack" | "$JQ" -c '.acked // []' 2>/dev/null)"
          [ -n "$acked" ] || acked="[]"
          for i in $(seq 0 $(( n - 1 ))); do
            job="$("$JQ" -c ".jobs[$i]" "$resp")"
            if printf '%s' "$acked" | "$JQ" -e --arg id "$(printf '%s' "$job" | "$JQ" -r '.id')" 'index($id) != null' >/dev/null 2>&1; then
              run_job "$job" 9>&- &
            fi
          done
        fi
        ;;
      410)
        log "this server was removed from Corypt; stopping. Run 'corypt uninstall' to clean up."
        touch "$REMOVED_FLAG"; exit 0 ;;
      401)
        log "Corypt rejected this server's secret. Run the install command again with a new code."; exit 3 ;;
      403)
        log "the Corypt account is disabled; checking again in 5 minutes"; sleep 300 ;;
      *)
        log "could not reach Corypt (HTTP $HTTP); trying again in ${backoff}s"
        sleep "$backoff"
        backoff=$(( backoff * 2 )); [ "$backoff" -gt 60 ] && backoff=60
        ;;
    esac
  done
}

cmd_start_bg() {
  [ -f "$REMOVED_FLAG" ] && exit 0
  agent_running && exit 0
  if command -v setsid >/dev/null 2>&1; then
    setsid nohup "$BIN_PATH" run >>"$LOG_FILE" 2>&1 </dev/null &
  else
    nohup "$BIN_PATH" run >>"$LOG_FILE" 2>&1 </dev/null &
  fi
  sleep 1
}

cmd_stop_bg() {
  local pid n
  pid="$(cat "$PID_FILE" 2>/dev/null)"
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
    kill "$pid" 2>/dev/null
    for n in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
    kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
  fi
  rm -f "$PID_FILE"
  return 0
}

# ---------------------------------------------------------------------
# Management commands
# ---------------------------------------------------------------------

cmd_status() {
  say "Corypt agent $CORYPT_VERSION"
  if ! load_conf; then say "Not installed. Run:  ./corypt.sh install CODE"; return 1; fi
  pick_jq || true
  say "  Server:   $NICKNAME ($SERVER_ID)"
  say "  Account:  $ACCOUNT"
  say "  Talks to: $API"
  local kind running="no"
  kind="$(service_kind)"
  case "$kind" in
    systemd) systemctl is-active --quiet corypt && running="yes" ;;
    openrc) rc-service corypt status >/dev/null 2>&1 && running="yes" ;;
    *) agent_running && running="yes" ;;
  esac
  say "  Service:  $kind, running: $running"
  [ -f "$REMOVED_FLAG" ] && say "  Note:     this server was removed on corypt.com"
  do_hello
  case "$HTTP" in
    200) say "  Corypt:   connected"; [ "$running" = yes ] && return 0; return 1 ;;
    410) say "  Corypt:   this server was removed on corypt.com"; return 1 ;;
    401) say "  Corypt:   secret rejected; install again with a new code"; return 1 ;;
    *)   say "  Corypt:   unreachable (HTTP $HTTP)"; return 1 ;;
  esac
}

cmd_logs() {
  if [ "$(service_kind)" = systemd ]; then
    if [ "${1:-}" = "-f" ]; then journalctl -u corypt -f --no-pager; else journalctl -u corypt -n 100 --no-pager; fi
  else
    [ -f "$LOG_FILE" ] || { say "No log yet at $LOG_FILE"; return 0; }
    if [ "${1:-}" = "-f" ]; then tail -n 100 -f "$LOG_FILE"; else tail -n 100 "$LOG_FILE"; fi
  fi
}

cmd_restart() {
  case "$(service_kind)" in
    systemd) systemctl restart corypt ;;
    openrc) rc-service corypt restart ;;
    *) cmd_stop_bg; sleep 1; "$BIN_PATH" start-bg ;;
  esac
  say "restarted"
}

cmd_update() {
  load_conf || die "not installed"
  ensure_deps >/dev/null
  local tmp new
  tmp="$(mktemp)"
  curl -fsSL --max-time 60 "$API/corypt.sh" -o "$tmp" || die "could not download $API/corypt.sh"
  grep -q 'CORYPT_AGENT_SCRIPT' "$tmp" || die "the downloaded file is not the Corypt agent"
  bash -n "$tmp" || die "the downloaded agent has a syntax error; keeping the current one"
  new="$(grep -m1 '^CORYPT_VERSION=' "$tmp" | cut -d'"' -f2)"
  chmod 755 "$tmp" && mv "$tmp" "$BIN_PATH"
  say "agent updated from $CORYPT_VERSION to ${new:-unknown}"
  cmd_restart
}

cmd_uninstall() {
  say "Removing the Corypt agent from this server"
  case "$(service_kind)" in
    systemd)
      systemctl disable --now corypt >/dev/null 2>&1
      rm -f "$UNIT_FILE"; systemctl daemon-reload ;;
    openrc)
      rc-service corypt stop >/dev/null 2>&1; rc-update del corypt default >/dev/null 2>&1
      rm -f "$OPENRC_FILE" ;;
  esac
  cmd_stop_bg
  rm -f "$CRON_FILE"
  if command -v crontab >/dev/null 2>&1; then
    crontab -l 2>/dev/null | grep -v 'corypt start-bg' | crontab - 2>/dev/null
  fi
  rm -rf "$CONF_DIR" "$STATE_DIR" "$LIB_DIR" "$PID_FILE" "$LOCK_FILE" "$BIN_PATH"
  good "service, configuration and agent removed"
  say "The server still appears on corypt.com until you remove it there (Servers, Remove)."
}

# ---------------------------------------------------------------------
# File tools: line-addressed reading and editing
# ---------------------------------------------------------------------

ftool_die() { printf 'CORYPT_ERROR: %s\n' "$*" >&2; exit 1; }

count_lines() {
  if [ ! -s "$1" ]; then printf '0'; return 0; fi
  awk 'END { print NR }' "$1"
}

ensure_trailing_newline() {
  [ -s "$1" ] || return 0
  if [ "$(tail -c 1 "$1" | wc -l)" -eq 0 ]; then printf '\n' >>"$1"; fi
}

print_tagged() { # FILE START END
  awk -v s="$2" -v e="$3" 'NR >= s && NR <= e { printf "<CORYPT_line%d>%s</CORYPT_line%d>\n", NR, $0, NR } NR > e { exit }' "$1"
}

is_uint() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }

cmd_read() {
  local file="${1:-}" start="${2:-}" end="${3:-}" total
  [ -n "$file" ] || ftool_die "read: no file given. Usage: corypt read FILE [START] [END]"
  [ -e "$file" ] || ftool_die "read: file not found: $file"
  [ -f "$file" ] || ftool_die "read: not a regular file: $file"
  [ -r "$file" ] || ftool_die "read: file not readable: $file"
  total="$(count_lines "$file")"
  [ -n "$start" ] || start=1
  [ -n "$end" ] || end="$total"
  is_uint "$start" || ftool_die "read: START must be a positive integer, got: $start"
  is_uint "$end" || ftool_die "read: END must be a positive integer, got: $end"
  [ "$start" -ge 1 ] || ftool_die "read: START must be 1 or more"
  printf 'CORYPT_FILE: %s\n' "$file"
  printf 'CORYPT_TOTAL_LINES: %s\n' "$total"
  if [ "$total" -eq 0 ]; then printf 'CORYPT_NOTE: file is empty\n'; return 0; fi
  if [ "$start" -gt "$total" ]; then printf 'CORYPT_NOTE: START (%s) is past the end of the file (%s lines)\n' "$start" "$total"; return 0; fi
  [ "$end" -ge "$start" ] || ftool_die "read: END ($end) must be >= START ($start)"
  [ "$end" -gt "$total" ] && end="$total"
  printf 'CORYPT_SHOWING_LINES: %s-%s\n' "$start" "$end"
  print_tagged "$file" "$start" "$end"
}

cmd_write() {
  local file="${1:-}" start="${2:-}" end="${3:-}"
  [ -n "$file" ] || ftool_die "write: no file given. Usage: corypt write FILE START END   (replacement on stdin)"
  [ -n "$start" ] || ftool_die "write: no START line given"
  [ -n "$end" ] || ftool_die "write: no END line given"
  [ -e "$file" ] || ftool_die "write: file not found: $file"
  [ -f "$file" ] || ftool_die "write: not a regular file: $file"
  [ -w "$file" ] || ftool_die "write: file not writable: $file"
  is_uint "$start" || ftool_die "write: START must be a positive integer, got: $start"
  is_uint "$end" || ftool_die "write: END must be a non-negative integer, got: $end"
  [ "$start" -ge 1 ] || ftool_die "write: START must be 1 or more"
  local prev=$(( start - 1 ))
  [ "$end" -ge "$prev" ] || ftool_die "write: END ($end) must be >= START-1 ($prev). Use END = START-1 to INSERT before line START."

  local tmpdir
  tmpdir="$(mktemp -d)" || ftool_die "write: could not create a temp dir"
  # shellcheck disable=SC2064
  trap "rm -rf '$tmpdir'" EXIT
  cat >"$tmpdir/raw"
  grep -q '^[[:space:]]*<CORYPT_REPLACE_WITH>[[:space:]]*$' "$tmpdir/raw" || ftool_die "write: opening tag <CORYPT_REPLACE_WITH> not found on a line of its own"
  grep -q '^[[:space:]]*</CORYPT_REPLACE_WITH>[[:space:]]*$' "$tmpdir/raw" || ftool_die "write: closing tag </CORYPT_REPLACE_WITH> not found on a line of its own"
  awk '
    !started && /^[[:space:]]*<CORYPT_REPLACE_WITH>[[:space:]]*$/ { started = 1; next }
    started && /^[[:space:]]*<\/CORYPT_REPLACE_WITH>[[:space:]]*$/ { exit }
    started { print }
  ' "$tmpdir/raw" >"$tmpdir/payload"
  ensure_trailing_newline "$tmpdir/payload"

  local total
  total="$(count_lines "$file")"
  [ "$start" -le $(( total + 1 )) ] || ftool_die "write: START ($start) is past the end of the file. It has $total lines; the largest START for appending is $(( total + 1 ))."
  [ "$end" -le "$total" ] || ftool_die "write: END ($end) is past the end of the file. It has $total lines."

  cp -p "$file" "$file.corypt.bak" || ftool_die "write: could not create the backup $file.corypt.bak"
  cp "$file" "$tmpdir/work"
  ensure_trailing_newline "$tmpdir/work"
  {
    [ "$prev" -ge 1 ] && head -n "$prev" "$tmpdir/work"
    cat "$tmpdir/payload"
    tail -n +"$(( end + 1 ))" "$tmpdir/work"
  } >"$tmpdir/out" || ftool_die "write: failed while assembling the new file"
  cat "$tmpdir/out" >"$file" || ftool_die "write: failed writing $file"

  local inserted new_total new_end cs ce
  inserted="$(count_lines "$tmpdir/payload")"
  new_total="$(count_lines "$file")"
  new_end=$(( prev + inserted ))
  printf 'CORYPT_WRITE_OK: %s\n' "$file"
  printf 'CORYPT_REPLACED_LINES: %s-%s\n' "$start" "$end"
  printf 'CORYPT_INSERTED_LINES: %s\n' "$inserted"
  printf 'CORYPT_TOTAL_LINES_BEFORE: %s\n' "$total"
  printf 'CORYPT_TOTAL_LINES_AFTER: %s\n' "$new_total"
  printf 'CORYPT_BACKUP: %s.corypt.bak\n' "$file"
  if [ "$inserted" -eq 0 ]; then
    printf 'CORYPT_NOTE: the replacement was empty, so lines %s-%s were deleted.\n' "$start" "$end"
    return 0
  fi
  cs=$(( prev - 2 )); [ "$cs" -lt 1 ] && cs=1
  ce=$(( new_end + 2 )); [ "$ce" -gt "$new_total" ] && ce="$new_total"
  printf 'CORYPT_VERIFY (lines %s-%s after the edit):\n' "$cs" "$ce"
  print_tagged "$file" "$cs" "$ce"
}

cmd_grep() {
  local pattern="${1:-}" file="${2:-}" context="${3:-0}" nums n lo hi
  [ -n "$pattern" ] || ftool_die "grep: no pattern given. Usage: corypt grep PATTERN FILE [CONTEXT]"
  [ -n "$file" ] || ftool_die "grep: no file given. Usage: corypt grep PATTERN FILE [CONTEXT]"
  [ -f "$file" ] || ftool_die "grep: file not found: $file"
  is_uint "$context" || ftool_die "grep: CONTEXT must be a non-negative integer"
  printf 'CORYPT_FILE: %s\n' "$file"
  printf 'CORYPT_TOTAL_LINES: %s\n' "$(count_lines "$file")"
  printf 'CORYPT_GREP: %s\n' "$pattern"
  nums="$(grep -n -- "$pattern" "$file" 2>/dev/null | cut -d: -f1)"
  if [ -z "$nums" ]; then printf 'CORYPT_NOTE: no match\n'; return 0; fi
  for n in $nums; do
    lo=$(( n - context )); [ "$lo" -lt 1 ] && lo=1
    hi=$(( n + context ))
    printf 'CORYPT_MATCH_AT_LINE: %s\n' "$n"
    print_tagged "$file" "$lo" "$hi"
  done
}

cmd_lines() {
  local file="${1:-}"
  [ -n "$file" ] || ftool_die "lines: no file given. Usage: corypt lines FILE"
  [ -f "$file" ] || ftool_die "lines: file not found: $file"
  printf 'CORYPT_FILE: %s\n' "$file"
  printf 'CORYPT_TOTAL_LINES: %s\n' "$(count_lines "$file")"
}

# ---- native file helpers used by job_native (no CORYPT tags needed) ----

# nf_list DIR [DEPTH] : a simple, depth-limited tree, skipping node_modules/.git
nf_list() {
  local dir="${1:-}" depth="${2:-2}"
  [ -n "$dir" ] || ftool_die "file_list: no path given"
  [ -d "$dir" ] || ftool_die "file_list: not a directory: $dir"
  is_uint "$depth" || depth=2
  [ "$depth" -ge 1 ] || depth=1
  printf 'CORYPT_DIR: %s\n' "$dir"
  find "$dir" -mindepth 1 -maxdepth "$depth" \( -name node_modules -o -name .git \) -prune -o -print 2>/dev/null \
    | LC_ALL=C sort \
    | while IFS= read -r p; do
        case "$p" in *.corypt.bak) continue ;; esac
        local rel="${p#"$dir"/}"
        if [ -d "$p" ]; then printf '  %-9s %s/\n' "dir" "$rel"
        else printf '  %-9s %s\n' "$(file_size "$p")" "$rel"; fi
      done
}

# nf_edit FILE START END REPLACEMENT_FILE [DATAOUT]
# Replaces lines START..END (inclusive). END=START-1 inserts before START.
# Empty replacement deletes. No CORYPT_REPLACE_WITH tags: the replacement
# file IS the replacement text, byte for byte.
nf_edit() {
  local file="${1:-}" start="${2:-}" end="${3:-}" rep="${4:-}" dataout="${5:-}"
  [ -n "$file" ] || ftool_die "file_edit: no path given"
  [ -f "$file" ] || ftool_die "file_edit: file not found: $file"
  [ -w "$file" ] || ftool_die "file_edit: file not writable: $file"
  is_uint "$start" || ftool_die "file_edit: start must be a positive integer, got: $start"
  is_uint "$end" || ftool_die "file_edit: end must be a non-negative integer, got: $end"
  [ "$start" -ge 1 ] || ftool_die "file_edit: start must be 1 or more"
  local prev=$(( start - 1 ))
  [ "$end" -ge "$prev" ] || ftool_die "file_edit: end ($end) must be >= start-1 ($prev). Use end = start-1 to INSERT before start."
  local total; total="$(count_lines "$file")"
  [ "$start" -le $(( total + 1 )) ] || ftool_die "file_edit: start ($start) is past the end of the file ($total lines)."
  [ "$end" -le "$total" ] || ftool_die "file_edit: end ($end) is past the end of the file ($total lines)."

  ensure_trailing_newline "$rep"
  cp -p "$file" "$file.corypt.bak" || ftool_die "file_edit: could not create the backup $file.corypt.bak"
  local tmpdir; tmpdir="$(mktemp -d)" || ftool_die "file_edit: could not create a temp dir"
  cp "$file" "$tmpdir/work"; ensure_trailing_newline "$tmpdir/work"
  {
    [ "$prev" -ge 1 ] && head -n "$prev" "$tmpdir/work"
    cat "$rep"
    tail -n +"$(( end + 1 ))" "$tmpdir/work"
  } >"$tmpdir/out" || { rm -rf "$tmpdir"; ftool_die "file_edit: failed while assembling the new file"; }
  cat "$tmpdir/out" >"$file" || { rm -rf "$tmpdir"; ftool_die "file_edit: failed writing $file"; }
  rm -rf "$tmpdir"

  local inserted new_total new_end cs ce
  inserted="$(count_lines "$rep")"
  new_total="$(count_lines "$file")"
  new_end=$(( prev + inserted ))
  printf 'CORYPT_EDIT_OK: %s\n' "$file"
  printf 'CORYPT_REPLACED_LINES: %s-%s\n' "$start" "$end"
  printf 'CORYPT_INSERTED_LINES: %s\n' "$inserted"
  printf 'CORYPT_TOTAL_LINES_BEFORE: %s\n' "$total"
  printf 'CORYPT_TOTAL_LINES_AFTER: %s\n' "$new_total"
  printf 'CORYPT_BACKUP: %s.corypt.bak\n' "$file"
  if [ "$inserted" -eq 0 ]; then
    printf 'CORYPT_NOTE: the replacement was empty, so lines %s-%s were deleted.\n' "$start" "$end"
  else
    cs=$(( prev - 2 )); [ "$cs" -lt 1 ] && cs=1
    ce=$(( new_end + 2 )); [ "$ce" -gt "$new_total" ] && ce="$new_total"
    printf 'CORYPT_VERIFY (lines %s-%s after the edit):\n' "$cs" "$ce"
    print_tagged "$file" "$cs" "$ce"
  fi
  if [ -n "$dataout" ]; then
    "$JQ" -n --arg p "$file" --argjson rs "$start" --argjson re "$end" --argjson ins "$inserted" \
         --argjson tb "$total" --argjson ta "$new_total" \
         '{path:$p, replaced_start:$rs, replaced_end:$re, inserted_lines:$ins, total_before:$tb, total_after:$ta, backup:($p+".corypt.bak")}' \
         >"$dataout" 2>/dev/null || true
  fi
}

# ---------------------------------------------------------------------
# Phase 1 (agent 1.2.0): importing existing projects, ports, firewalls,
# services. All of these are native jobs: every argument arrives through
# jq, never through a shell string, so a path or a field can never run
# as a command. Called as: ( ext_native JOB KIND DATAOUT ) >out 2>err
# ---------------------------------------------------------------------

EXT_JOB=""
ext_p() { printf '%s' "$EXT_JOB" | "$JQ" -r "$1"; }
ext_t() { local s="$1"; shift; if [ ${#TIMEOUT_CMD[@]} -gt 0 ]; then "${TIMEOUT_CMD[@]}" "$s" "$@"; else "$@"; fi; }

ext_native() {
  EXT_JOB="$1"
  local kind="$2" dataout="$3" intro rc
  case "$kind" in
    project_inspect) ext_inspect "$(ext_p '.payload.path // ""')" "$dataout" ;;
    project_adopt)
      intro="$(mktemp)"
      printf '%s' "$EXT_JOB" | "$JQ" -j '.payload.intro // ""' >"$intro"
      ext_adopt "$(ext_p '.payload.source // ""')" "$(ext_p '.payload.root // ""')" "$(ext_p '.payload.mode // "link"')" "$intro" "$dataout"
      rc=$?; rm -f "$intro"; return $rc ;;
    net)          ext_net "$(ext_p '.payload.op // ""')" "$dataout" ;;
    service_up)   ext_service_up "$dataout" ;;
    vfile_read)   ext_vfile_read "$dataout" ;;
    vfile_write)  ext_vfile_write "$dataout" ;;
    vfile_edit)   ext_vfile_edit "$dataout" ;;
    probe_get)    ext_probe_get "$dataout" ;;
    service_down) ext_service_down "$(ext_p '.payload.root // ""')" "$(ext_p '.payload.name // ""')" "$dataout" ;;
    trail_append) ext_trail_append "$dataout" ;;   # CORYPT_TRAIL
    trail_read)   ext_trail_read "$dataout" ;;
    trail_state)  ext_trail_state "$dataout" ;;
    trail_bundle) ext_trail_bundle "$dataout" ;;
    *) printf 'unknown job kind: %s' "$kind" >&2; return 1 ;;
  esac
}

# ---- versioned file tools for teams (agent 1.4.0) ----
# Every read returns the file's sha256. A write or edit may carry the sha256
# the agent last saw; if the file changed since, nothing is written. A lock
# per file makes check-and-write one step, so two agents editing the same
# file at the same moment can never both succeed on a stale view.

ext_sha() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum -- "$1" 2>/dev/null | cut -c1-64
  elif command -v shasum >/dev/null 2>&1; then shasum -a 256 -- "$1" 2>/dev/null | cut -c1-64
  else openssl dgst -sha256 -r "$1" 2>/dev/null | cut -c1-64; fi
}
ext_file_lock() {
  command -v flock >/dev/null 2>&1 || return 0
  mkdir -p "$STATE_DIR/locks"
  exec 8>"$STATE_DIR/locks/$(printf '%s' "$1" | ext_sha /dev/stdin 2>/dev/null | cut -c1-32)"
  flock -w 15 8 || ftool_die "the file is locked by another edit; try again"
}
ext_expect() { # CURRENT EXPECTED REQUIRE DATAOUT -> 0 ok, 1 refused (data says why)
  local cur="$1" exp="$2" req="$3" dataout="$4"
  if [ -n "$exp" ] && [ "$exp" != "$cur" ]; then
    "$JQ" -n --arg c "$cur" --arg e "$exp" '{conflict:true, current_sha256:$c, expected_sha256:$e}' >"$dataout"
    printf 'CORYPT_CONFLICT: the file changed since you read it (expected %s, now %s)\n' "$exp" "$cur" >&2; return 1
  fi
  if [ -z "$exp" ] && [ "$req" = true ] && [ "$cur" != new ]; then
    "$JQ" -n --arg c "$cur" '{need_sha:true, current_sha256:$c}' >"$dataout"
    printf 'CORYPT_NEED_SHA: other agents share this project; read the file first and pass expected_sha256\n' >&2; return 1
  fi
  return 0
}
ext_cur_sha() { if [ -e "$1" ]; then ext_sha "$1"; else printf new; fi; }

ext_vfile_read() {
  local dataout="$1" path start end sha total
  path="$(ext_p '.payload.path // ""')"; start="$(ext_p '.payload.start // empty')"; end="$(ext_p '.payload.end // empty')"
  ( cmd_read "$path" "$start" "$end" ) || exit 1
  sha="$(ext_sha "$path")"; total="$(count_lines "$path")"
  printf 'CORYPT_FILE_SHA256: %s\n' "$sha"
  "$JQ" -n --arg p "$path" --arg s "$sha" --argjson t "${total:-0}" '{path:$p, sha256:$s, total_lines:$t}' >"$dataout"
}

ext_vfile_write() {
  local dataout="$1" path exp req cur new bytes lines created=false
  path="$(ext_p '.payload.path // ""')"; exp="$(ext_p '.payload.expected_sha256 // ""')"; req="$(ext_p '.payload.require_sha // false')"
  case "$path" in /*) ;; *) ftool_die "file_write: path must be absolute" ;; esac
  ext_file_lock "$path"
  cur="$(ext_cur_sha "$path")"
  ext_expect "$cur" "$exp" "$req" "$dataout" || exit 1
  [ "$cur" = new ] && created=true
  mkdir -p "$(dirname "$path")" || ftool_die "file_write: could not create the folder"
  printf '%s' "$EXT_JOB" | "$JQ" -j '.payload.content // ""' >"$path" || ftool_die "file_write: could not write $path"
  new="$(ext_sha "$path")"; bytes="$(file_size "$path")"; lines="$(count_lines "$path")"
  "$JQ" -n --arg p "$path" --argjson b "$bytes" --argjson l "$lines" --argjson c "$created" --arg s "$new" --arg o "$cur" \
    '{path:$p, bytes:$b, lines:$l, created:$c, sha256:$s, previous_sha256:$o}' >"$dataout"
  printf 'wrote %s (%s bytes, %s lines)\nCORYPT_FILE_SHA256: %s\n' "$path" "$bytes" "$lines" "$new"
}

ext_vfile_edit() {
  local dataout="$1" path exp req start end cur rep new
  path="$(ext_p '.payload.path // ""')"; exp="$(ext_p '.payload.expected_sha256 // ""')"; req="$(ext_p '.payload.require_sha // false')"
  start="$(ext_p '.payload.start // empty')"; end="$(ext_p '.payload.end // empty')"
  [ -f "$path" ] || ftool_die "file_edit: file not found: $path"
  ext_file_lock "$path"
  cur="$(ext_sha "$path")"
  ext_expect "$cur" "$exp" "$req" "$dataout" || exit 1
  rep="$(mktemp)"; printf '%s' "$EXT_JOB" | "$JQ" -j '.payload.replacement // ""' >"$rep"
  ( nf_edit "$path" "$start" "$end" "$rep" "$dataout.edit" ) || { rm -f "$rep"; exit 1; }
  rm -f "$rep"
  new="$(ext_sha "$path")"
  printf 'CORYPT_FILE_SHA256: %s\n' "$new"
  "$JQ" --arg s "$new" --arg o "$cur" '. + {sha256:$s, previous_sha256:$o}' "$dataout.edit" >"$dataout" 2>/dev/null \
    || "$JQ" -n --arg s "$new" --arg o "$cur" '{sha256:$s, previous_sha256:$o}' >"$dataout"
  rm -f "$dataout.edit"
}

# ---- the project trail (agent 1.6.0) --------------------------------
# .corypt/HISTORY.xml, .corypt/CHANGELOG.xml and .corypt/plan/DECISIONS.xml
# are append-only XML files in the PROJECT folder. Corypt builds each entry;
# the agent only adds it, under the same per-file lock the versioned file
# tools use, so two agents appending at once never interleave or share a
# sequence number. Nothing here ever touches a project's own code.

ext_trail_root() { # ROOT -> dies unless it is an absolute folder without ".."
  case "$1" in /*) ;; *) ftool_die "trail: root must be an absolute path" ;; esac
  case "$1" in *..*) ftool_die "trail: root may not contain .." ;; esac
  [ -d "$1" ] || ftool_die "trail: project folder not found: $1"
}

ext_trail_append() {
  local dataout="$1" root file tag elem idattr prefix path close seqf sc last seq entry tmp size t suffix cut statef ph key
  local LC_ALL=C
  root="$(ext_p '.payload.root // ""')"; file="$(ext_p '.payload.file // ""')"; tag="$(ext_p '.payload.tag // ""')"
  elem="$(ext_p '.payload.elem // ""')"; idattr="$(ext_p '.payload.idattr // "seq"')"; prefix="$(ext_p '.payload.prefix // ""')"
  ext_trail_root "$root"
  case "$file" in HISTORY.xml|CHANGELOG.xml|plan/DECISIONS.xml) ;; *) ftool_die "trail: not a trail file: $file" ;; esac
  case "$tag" in history|changelog|decisions) ;; *) ftool_die "trail: bad root tag" ;; esac
  case "$elem" in run|entry|decision|anti-goal) ;; *) ftool_die "trail: bad element" ;; esac
  case "$idattr" in seq|id) ;; *) ftool_die "trail: bad id attribute" ;; esac
  case "$prefix" in ''|D|A) ;; *) ftool_die "trail: bad id prefix" ;; esac
  path="$root/.corypt/$file"
  mkdir -p "$(dirname "$path")" "$root/.corypt/.seq" || ftool_die "trail: could not create $(dirname "$path")"
  ext_file_lock "$path"
  close="</$tag>"
  if [ ! -s "$path" ]; then
    { printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'; ext_p '.payload.open // ""'; printf '\n%s\n' "$close"; } >"$path" \
      || ftool_die "trail: could not create $path"
  fi
  # The next number comes from our counter, reserved BEFORE the write: a crash
  # can leave a gap, never a repeat. Only without a counter (a folder copied
  # without .corypt/.seq) is the last number read back from the file.
  seqf="$root/.corypt/.seq/$(printf '%s' "$file.$elem" | tr '/' '_')"
  sc="$(cat "$seqf" 2>/dev/null)"
  if ! is_uint "$sc"; then
    sc="$(tail -c 262144 "$path" | grep -a -o "^  <$elem $idattr=\"$prefix[0-9]*\"" | tail -n 1 | tr -cd '0-9')"
    is_uint "$sc" || sc=0
  fi
  seq=$(( sc + 1 ))
  printf '%s' "$seq" >"$seqf" || ftool_die "trail: could not reserve a number in $seqf"
  tmp="$(mktemp)"
  printf '%s' "$EXT_JOB" | "$JQ" -j '.payload.entry // ""' >"$tmp"
  entry="$(cat "$tmp")"; rm -f "$tmp"
  [ -n "$entry" ] || ftool_die "trail: empty entry"
  entry="${entry/__CORYPT_SEQ__/$seq}"
  # The phase comes from state.json at the moment of writing (none in draft).
  ph=""
  statef="$root/.corypt/state.json"
  if [ -f "$statef" ]; then
    ph="$("$JQ" -r 'if (.lifecycle // "dev") == "draft" then "" else ((.current_phase // "") | tostring) end' "$statef" 2>/dev/null)"
    case "$ph" in *[!A-Za-z0-9._-]*) ph="" ;; esac
  fi
  if [ -n "$ph" ]; then entry="${entry/__CORYPT_PHASEATTR__/ phase=\"$ph\"}"; else entry="${entry/__CORYPT_PHASEATTR__/}"; fi
  size="$(file_size "$path")"
  t="$(tail -c 512 "$path"; printf x)"; t="${t%x}"   # keep the trailing newline: it counts
  if [[ "$t" == *"$close"* ]]; then
    suffix="${t##*"$close"}"
    cut=$(( size - ${#suffix} - ${#close} ))
    truncate -s "$cut" "$path" || ftool_die "trail: could not open $path for appending"
  fi
  # If the closing tag was missing (a write cut short), the entry still goes
  # in and the closing tag is put back: the file heals itself.
  printf '%s\n\n%s\n' "$entry" "$close" >>"$path" || ftool_die "trail: could not append to $path"
  key="$(ext_p '.payload.state_key // ""')"
  if [ -n "$key" ]; then ext_trail_state_merge "$root" "$("$JQ" -nc --arg k "$key" --argjson v "$seq" '{($k): $v}')" "$(ext_p '.payload.state_create // false')"; fi
  "$JQ" -n --arg p "$path" --argjson s "$seq" --arg id "$prefix$seq" --argjson b "$(file_size "$path")" '{path:$p, seq:$s, id:$id, bytes:$b}' >"$dataout"
  printf 'appended %s%s to %s\n' "$prefix" "$seq" "$path"
}

# ext_trail_state_merge ROOT JSON_OBJECT CREATE(true|false): merge keys into
# .corypt/state.json under its own lock. Without CREATE a missing file stays missing.
ext_trail_state_merge() {
  local root="$1" obj="$2" create="$3" statef base
  statef="$root/.corypt/state.json"
  [ -f "$statef" ] || [ "$create" = true ] || return 0
  mkdir -p "$STATE_DIR/locks"
  exec 7>"$STATE_DIR/locks/state-$(printf '%s' "$statef" | ext_sha /dev/stdin 2>/dev/null | cut -c1-32)"
  command -v flock >/dev/null 2>&1 && { flock -w 15 7 || ftool_die "trail: state.json is locked; try again"; }
  if [ -f "$statef" ] && "$JQ" -e 'type == "object"' "$statef" >/dev/null 2>&1; then base="$(cat "$statef")"; else base="$(printf '%s' "$EXT_JOB" | "$JQ" -c '.payload.state_init // {} | if type == "object" then . else {} end' 2>/dev/null)"; [ -n "$base" ] || base="{}"; fi   # CORYPT_DRAFT
  printf '%s' "$base" | "$JQ" --argjson o "$obj" '. + $o' >"$statef.tmp" 2>/dev/null && mv "$statef.tmp" "$statef"
  rm -f "$statef.tmp"
  exec 7>&-
}

ext_trail_state() {
  local dataout="$1" root obj create
  root="$(ext_p '.payload.root // ""')"
  ext_trail_root "$root"
  obj="$(printf '%s' "$EXT_JOB" | "$JQ" -c '.payload.set // {}')"
  create="$(ext_p '.payload.create // false')"
  mkdir -p "$root/.corypt"
  ext_trail_state_merge "$root" "$obj" "$create"
  if [ "$(ext_p '.payload.clear_scratch // false')" = true ] && [ -d "$root/.corypt/scratch" ]; then
    rm -rf -- "$root/.corypt/scratch" || ftool_die "trail: could not delete $root/.corypt/scratch"
  fi
  if [ -f "$root/.corypt/state.json" ]; then "$JQ" -c '{state: .}' "$root/.corypt/state.json" >"$dataout"; else printf '{"state":null}' >"$dataout"; fi
  printf 'state.json updated\n'
}

# trail_read: bytes [offset, end) of a trail file, both moved forward to a line
# boundary, so a window never starts or ends in the middle of a line.
ext_trail_read() {
  local dataout="$1" path size off end bytes b skip
  local LC_ALL=C
  path="$(ext_p '.payload.path // ""')"
  case "$path" in /*/.corypt/*.xml) ;; *) ftool_die "trail_read: not a trail file: $path" ;; esac
  case "$path" in *..*) ftool_die "trail_read: path may not contain .." ;; esac
  if [ ! -f "$path" ]; then printf '{"exists":false,"size":0,"offset":0,"end":0}' >"$dataout"; return 0; fi
  size="$(file_size "$path")"
  end="$(ext_p '.payload.end // empty')"; is_uint "$end" && [ "$end" -le "$size" ] || end="$size"
  bytes="$(ext_p '.payload.bytes // 180000')"; is_uint "$bytes" || bytes=180000
  off="$(ext_p '.payload.offset // empty')"
  if ! is_uint "$off"; then off=$(( end - bytes )); [ "$off" -lt 0 ] && off=0; fi
  [ "$off" -gt "$end" ] && off="$end"
  if [ "$off" -gt 0 ]; then
    b="$(tail -c +"$off" "$path" | head -c 1 | od -An -tx1 | tr -d ' \n')"
    if [ "$b" != "0a" ]; then skip="$(tail -c +"$(( off + 1 ))" "$path" | head -n 1 | wc -c | tr -d ' ')"; off=$(( off + skip )); fi
    [ "$off" -gt "$end" ] && off="$end"
  fi
  if [ "$end" -lt "$size" ] && [ "$end" -gt 0 ]; then
    b="$(tail -c +"$end" "$path" | head -c 1 | od -An -tx1 | tr -d ' \n')"
    if [ "$b" != "0a" ]; then skip="$(tail -c +"$(( end + 1 ))" "$path" | head -n 1 | wc -c | tr -d ' ')"; end=$(( end + skip )); fi
  fi
  if [ "$end" -gt "$off" ]; then tail -c +"$(( off + 1 ))" "$path" | head -c "$(( end - off ))"; fi
  "$JQ" -n --argjson s "$size" --argjson o "$off" --argjson e "$end" '{exists:true, size:$s, offset:$o, end:$e}' >"$dataout"
}

# trail_bundle: the planning files of a project in one job. Small files come
# with their content; big ones with their size only (read those with file_read).
ext_trail_bundle() {
  local dataout="$1" root rel f max acc="{}" dirs="{}" one sz names
  root="$(ext_p '.payload.root // ""')"
  ext_trail_root "$root"
  max="$(ext_p '.payload.max_bytes // 200000')"; is_uint "$max" || max=200000
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    case "$rel" in /*|*..*) continue ;; esac
    f="$root/$rel"
    if [ -f "$f" ]; then
      sz="$(file_size "$f")"
      if [ "$sz" -le "$max" ]; then one="$("$JQ" -n --rawfile c "$f" --argjson s "$sz" '{exists:true, size:$s, content:$c}')"
      else one="$("$JQ" -n --argjson s "$sz" '{exists:true, size:$s, content:null}')"; fi
    else one='{"exists":false}'; fi
    acc="$(printf '%s' "$acc" | "$JQ" -c --arg k "$rel" --argjson v "$one" '. + {($k): $v}')"
  done < <(printf '%s' "$EXT_JOB" | "$JQ" -r '(.payload.files // [])[]')
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    case "$rel" in /*|*..*) continue ;; esac
    if [ -d "$root/$rel" ]; then names="$(cd "$root/$rel" && ls -1A 2>/dev/null | "$JQ" -R . | "$JQ" -sc .)"; else names="null"; fi
    dirs="$(printf '%s' "$dirs" | "$JQ" -c --arg k "$rel" --argjson v "${names:-null}" '. + {($k): $v}')"
  done < <(printf '%s' "$EXT_JOB" | "$JQ" -r '(.payload.dirs // [])[]')
  "$JQ" -n --argjson f "$acc" --argjson d "$dirs" '{files:$f, dirs:$d}' >"$dataout"
  printf 'bundle of %s\n' "$root"
}

# ---- reachability probe (agent 1.3.0) ----
# Only for servers whose owner ticked "Let my online servers help check
# reachability". One plain GET, no redirects, no proxy, no cookies, body cut
# at 4 KB, 8 s max. The target IP is decided and pinned by Corypt (--resolve),
# so this server never resolves a name itself, and private, loopback,
# link-local and metadata addresses are refused here as well. A local limit
# (1 per 4 s, 25 per minute) backs up the limits Corypt enforces.

ext_public_ip() {
  local ip="$1" a b
  case "$ip" in
    *:*)
      case "$(printf '%s' "$ip" | tr 'A-F' 'a-f')" in ::|::1|fe8*|fe9*|fea*|feb*|fc*|fd*|ff*|::ffff:*|64:ff9b:*|2001:db8:*) return 1 ;; esac
      printf '%s' "$ip" | grep -Eq '^[0-9A-Fa-f:]{2,39}$' ;;
    *)
      printf '%s' "$ip" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || return 1
      a="${ip%%.*}"; b="${ip#*.}"; b="${b%%.*}"
      case "$a" in 0|10|127) return 1 ;; esac
      [ "$a" -ge 224 ] && return 1
      [ "$a" = 169 ] && [ "$b" = 254 ] && return 1
      [ "$a" = 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ] && return 1
      [ "$a" = 192 ] && [ "$b" = 168 ] && return 1
      [ "$a" = 100 ] && [ "$b" -ge 64 ] && [ "$b" -le 127 ] && return 1
      [ "$a" = 198 ] && { [ "$b" = 18 ] || [ "$b" = 19 ]; } && return 1
      return 0 ;;
  esac
}

ext_probe_rate_ok() {
  local f="$STATE_DIR/probe.times" t cut n
  t="$(date +%s)"; cut=$(( t - 60 ))
  mkdir -p "$STATE_DIR"
  [ -f "$f" ] && awk -v c="$cut" '$1 > c' "$f" >"$f.tmp" 2>/dev/null && mv "$f.tmp" "$f"
  n=0; [ -f "$f" ] && n="$(wc -l <"$f" | tr -d ' ')"; is_uint "$n" || n=0
  [ "$n" -ge 25 ] && return 1
  if [ -s "$f" ] && [ $(( t - $(tail -n 1 "$f") )) -lt 4 ]; then return 1; fi
  printf '%s\n' "$t" >>"$f"
}

ext_probe_get() {
  local dataout="$1" url host port ip tmp res rc code tt cert=true ctype bytes err="" pin
  url="$(ext_p '.payload.url // ""')"; host="$(ext_p '.payload.host // ""')"
  port="$(ext_p '.payload.port // ""')"; ip="$(ext_p '.payload.ip // ""')"
  case "$url" in http://*|https://*) ;; *) ftool_die "probe_get: only http and https URLs" ;; esac
  printf '%s' "$url" | grep -Eq '^https?://[A-Za-z0-9.:-]+|^https?://\[[0-9A-Fa-f:]+\]' || ftool_die "probe_get: URL not allowed"
  printf '%s' "$url" | grep -q '@' && ftool_die "probe_get: URLs with a user name are not allowed"
  printf '%s' "$host" | grep -Eq '^[A-Za-z0-9.:-]{1,253}$' || ftool_die "probe_get: bad host"
  { is_uint "$port" && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; } || ftool_die "probe_get: bad port"
  ext_public_ip "$ip" || ftool_die "probe_get: the target is not a public address"
  ext_probe_rate_ok || ftool_die "probe_get: this server's checkpoint limit is reached (1 per 4 s, 25 per minute)"
  case "$ip" in *:*) pin="[$ip]" ;; *) pin="$ip" ;; esac
  local literal=false
  case "$host" in *:*) literal=true ;; *) printf '%s' "$host" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' && literal=true ;; esac
  if [ "$literal" = true ] && [ "$(printf '%s' "$host" | tr 'A-F' 'a-f')" != "$(printf '%s' "$ip" | tr 'A-F' 'a-f')" ]; then ftool_die "probe_get: host and pinned address differ"; fi
  tmp="$(mktemp -d)"
  local base=(-sS --noproxy '*' --proto '=http,https' --max-redirs 0 --connect-timeout 5 --max-time 8
              -A 'Corypt-Reachability-Check/1.0 (+https://corypt.com)' -H 'Accept: */*' -D "$tmp/h"
)
  [ "$literal" = true ] || base+=(--resolve "$host:$port:$pin")
  res="$(curl "${base[@]}" -o >(head -c 4096 >"$tmp/body") -w '%{http_code} %{time_total}' -- "$url" 2>"$tmp/err")"; rc=$?
  wait $! 2>/dev/null
  if [ "$rc" = 60 ] || [ "$rc" = 51 ]; then
    cert=false; : >"$tmp/h"
    res="$(curl "${base[@]}" -k -o >(head -c 4096 >"$tmp/body") -w '%{http_code} %{time_total}' -- "$url" 2>"$tmp/err")"; rc=$?
    wait $! 2>/dev/null
  fi
  sleep 0.1
  code="${res%% *}"; tt="${res#* }"
  [ "$rc" = 0 ] || [ "$rc" = 23 ] || err="$(head -c 300 "$tmp/err" | tr '\n' ' ')"
  ctype="$(tr -d '\r' <"$tmp/h" | grep -i -m1 '^content-type:' | cut -d: -f2- | sed 's/^ *//' | head -c 100)"
  bytes="$(file_size "$tmp/body")"
  "$JQ" -n --arg code "$code" --arg tt "$tt" --arg err "$err" --argjson rc "$rc" --argjson cert "$cert" \
        --arg ctype "$ctype" --argjson bytes "${bytes:-0}" --rawfile body "$tmp/body" \
    '{status_code:(if ($code|tonumber? // 0) > 0 then ($code|tonumber) else null end), seconds:($tt|tonumber? // null),
      error:(if $err == "" then null else $err end), curl_exit:$rc, cert_valid:$cert, content_type:$ctype,
      bytes_read:$bytes, body_preview:($body | .[0:2000])}' >"$dataout"
  rm -rf "$tmp"
  printf 'CORYPT_PROBE: http=%s exit=%s\n' "${code:-none}" "$rc"
}

# ---- inspecting a project folder (read-only, never writes there) ----

EXT_MARKERS="package.json package-lock.json yarn.lock pnpm-lock.yaml bun.lockb deno.json requirements.txt pyproject.toml Pipfile setup.py manage.py app.py main.py wsgi.py go.mod Cargo.toml composer.json Gemfile pom.xml build.gradle build.gradle.kts Dockerfile docker-compose.yml docker-compose.yaml compose.yml compose.yaml Makefile Procfile ecosystem.config.js index.html server.js app.js index.js nginx.conf .env .nvmrc .python-version"

ext_inspect() {
  local src="${1:-}" dataout="${2:-}" real tmp m u un st ex size_kb files owner is_git=false gb="" gr="" gl="" has_env=false
  [ -n "$src" ] || ftool_die "project_inspect: no path given"
  case "$src" in /*) ;; *) ftool_die "project_inspect: the path must be absolute: $src" ;; esac
  [ -e "$src" ] || ftool_die "project_inspect: nothing exists at $src"
  real="$(readlink -f -- "$src" 2>/dev/null)"; [ -n "$real" ] || real="$src"
  [ -d "$real" ] || ftool_die "project_inspect: not a directory: $src"
  tmp="$(mktemp -d)" || ftool_die "project_inspect: could not create a temp dir"
  : >"$tmp/markers"; : >"$tmp/stacks"; : >"$tmp/units"; : >"$tmp/ports"; : >"$tmp/docker"; : >"$tmp/pkg"; : >"$tmp/readme"

  for m in $EXT_MARKERS; do [ -e "$real/$m" ] && printf '%s\n' "$m" >>"$tmp/markers"; done
  ls -A1 -- "$real" 2>/dev/null | head -n 80 >"$tmp/listing"
  size_kb="$(ext_t 20 du -sk -- "$real" 2>/dev/null | awk '{print $1}')"; is_uint "$size_kb" || size_kb=0
  files="$(ext_t 20 find "$real" -xdev \( -name node_modules -o -name .git -o -name vendor -o -name .venv -o -name venv -o -name __pycache__ -o -name target -o -name dist -o -name build \) -prune -o -type f -print 2>/dev/null | head -n 200000 | wc -l | tr -d ' ')"
  is_uint "$files" || files=0
  owner="$(stat -c %U -- "$real" 2>/dev/null)"

  {
    grep -qxE 'package\.json|deno\.json' "$tmp/markers" && echo node
    grep -qxE 'requirements\.txt|pyproject\.toml|Pipfile|setup\.py|manage\.py|wsgi\.py' "$tmp/markers" && echo python
    grep -qx 'go.mod' "$tmp/markers" && echo go
    grep -qx 'Cargo.toml' "$tmp/markers" && echo rust
    grep -qx 'composer.json' "$tmp/markers" && echo php
    grep -qx 'Gemfile' "$tmp/markers" && echo ruby
    grep -qxE 'pom\.xml|build\.gradle|build\.gradle\.kts' "$tmp/markers" && echo java
    grep -qxE 'Dockerfile|docker-compose\.ya?ml|compose\.ya?ml' "$tmp/markers" && echo docker
    if grep -qx 'index.html' "$tmp/markers" && ! grep -qxE 'package\.json|requirements\.txt|pyproject\.toml|go\.mod|Cargo\.toml|composer\.json|Gemfile' "$tmp/markers"; then echo static-site; fi
  } >"$tmp/stacks" 2>/dev/null

  if [ -f "$real/package.json" ]; then
    "$JQ" -c '{name:(.name // null), version:(.version // null), main:(.main // null),
              scripts:((.scripts // {}) | to_entries | .[0:20] | map(.value |= (tostring | .[0:200])) | from_entries),
              dependencies:((.dependencies // {}) | keys | .[0:40])}' "$real/package.json" >"$tmp/pkg" 2>/dev/null || : >"$tmp/pkg"
  fi
  for m in README.md README readme.md Readme.md README.txt README.rst; do
    if [ -f "$real/$m" ]; then head -c 3000 -- "$real/$m" >"$tmp/readme" 2>/dev/null; break; fi
  done
  [ -e "$real/.env" ] && has_env=true

  if [ -e "$real/.git" ]; then
    is_git=true
    if command -v git >/dev/null 2>&1; then
      gb="$(ext_t 5 git -c safe.directory='*' -C "$real" rev-parse --abbrev-ref HEAD 2>/dev/null | head -c 200)"
      gr="$(ext_t 5 git -c safe.directory='*' -C "$real" config --get remote.origin.url 2>/dev/null | sed -E 's#(://)[^/@]*@#\1#' | head -c 300)"
      gl="$(ext_t 5 git -c safe.directory='*' -C "$real" log -1 --format='%h %s (%cr)' 2>/dev/null | head -c 200)"
    fi
  fi

  for u in /etc/systemd/system/*.service /lib/systemd/system/*.service /usr/lib/systemd/system/*.service; do
    [ -f "$u" ] || continue
    if grep -qsF -- "$real" "$u" || { [ "$src" != "$real" ] && grep -qsF -- "$src" "$u"; }; then
      un="$(basename "$u")"
      st="$(systemctl is-active "$un" 2>/dev/null)"
      ex="$(grep -m1 '^ExecStart=' "$u" 2>/dev/null | head -c 300)"
      "$JQ" -nc --arg n "$un" --arg s "${st:-unknown}" --arg e "$ex" --arg f "$u" '{unit:$n, state:$s, exec:$e, file:$f}' >>"$tmp/units"
      [ "$(wc -l <"$tmp/units")" -ge 10 ] && break
    fi
  done

  local port addr proc pid cwd cmdl
  ext_listeners | while IFS=$'\t' read -r port addr proc pid; do
    is_uint "$pid" || continue
    cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null)"
    cmdl="$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | head -c 400)"
    case "$cwd/" in "$real/"*) ;; *) case "$cmdl" in *"$real"*) ;; *) continue ;; esac ;; esac
    "$JQ" -nc --arg p "$port" --arg a "$addr" --arg n "$proc" --arg i "$pid" '{port:($p|tonumber? // $p), address:$a, process:$n, pid:($i|tonumber? // null)}'
  done | awk '!seen[$0]++' | head -n 20 >"$tmp/ports"

  if command -v docker >/dev/null 2>&1; then
    local id nm img prt mounts wd
    ext_t 10 docker ps --format '{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}' 2>/dev/null | head -n 50 | while IFS='|' read -r id nm img prt; do
      mounts="$(ext_t 5 docker inspect -f '{{range .Mounts}}{{.Source}}|{{end}}' "$id" 2>/dev/null)"
      wd="$(ext_t 5 docker inspect -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)"
      case "|$mounts|$wd|" in *"$real"*) "$JQ" -nc --arg i "$id" --arg n "$nm" --arg m "$img" --arg p "$prt" '{id:$i, name:$n, image:$m, ports:$p}' ;; esac
    done >"$tmp/docker"
  fi

  "$JQ" -n --arg path "$src" --arg real "$real" --arg owner "$owner" \
    --argjson size_kb "$size_kb" --argjson files "$files" \
    --rawfile markers "$tmp/markers" --rawfile stacks "$tmp/stacks" --rawfile listing "$tmp/listing" --rawfile readme "$tmp/readme" \
    --slurpfile units "$tmp/units" --slurpfile ports "$tmp/ports" --slurpfile docker "$tmp/docker" --slurpfile pkg "$tmp/pkg" \
    --argjson is_git "$is_git" --arg gb "$gb" --arg gr "$gr" --arg gl "$gl" --argjson has_env "$has_env" \
    'def lines: split("\n") | map(select(length > 0));
     {path:$path, real_path:$real, owner:$owner, size_kb:$size_kb, files:$files,
      markers:($markers|lines), stacks:($stacks|lines), listing:($listing|lines), readme_head:$readme,
      git:(if $is_git then {branch:$gb, remote:$gr, last_commit:$gl} else null end),
      has_env_file:$has_env, package:($pkg[0] // null),
      systemd_units:$units, listening:$ports, docker:$docker}' >"$dataout" \
    || { rm -rf "$tmp"; ftool_die "project_inspect: could not build the report"; }

  printf 'CORYPT_INSPECT: %s\n' "$real"
  printf 'CORYPT_STACK: %s\n' "$(tr '\n' ' ' <"$tmp/stacks")"
  printf 'CORYPT_SIZE_KB: %s (%s files)\n' "$size_kb" "$files"
  rm -rf "$tmp"
}

# ---- adopting: make /srv/corypt/<slug> next to the code, never inside it ----

ext_adopt() {
  local src="${1:-}" root="${2:-}" mode="${3:-link}" intro="${4:-}" dataout="${5:-}" real slug copied_kb=0
  case "$root" in /srv/corypt/*) ;; *) ftool_die "project_adopt: the Corypt folder must be under /srv/corypt/" ;; esac
  slug="${root#/srv/corypt/}"
  printf '%s' "$slug" | grep -Eq '^[a-z0-9][a-z0-9-]{0,19}$' || ftool_die "project_adopt: bad project folder name: $slug"
  case "$mode" in link|copy) ;; *) ftool_die "project_adopt: mode must be link or copy" ;; esac
  { [ -n "$src" ] && [ -e "$src" ]; } || ftool_die "project_adopt: nothing exists at $src"
  real="$(readlink -f -- "$src" 2>/dev/null)"
  [ -n "$real" ] && [ -d "$real" ] || ftool_die "project_adopt: not a directory: $src"
  case "$real/" in /srv/corypt/*) ftool_die "project_adopt: $real is already inside /srv/corypt" ;; esac
  if [ -e "$root" ] && [ -n "$(ls -A -- "$root" 2>/dev/null)" ]; then ftool_die "project_adopt: $root already exists and is not empty"; fi
  mkdir -p "$root/.corypt/mcp/tools" "$root/.corypt/services" || ftool_die "project_adopt: could not create $root"
  if [ "$mode" = link ]; then
    ln -s -- "$real" "$root/source" || { rm -rf -- "$root"; ftool_die "project_adopt: could not link $real"; }
  else
    if command -v rsync >/dev/null 2>&1; then rsync -a -- "$real/" "$root/source/"; else cp -a -- "$real" "$root/source"; fi \
      || { rm -rf -- "$root"; ftool_die "project_adopt: copying $real failed (nothing was kept)"; }
    copied_kb="$(du -sk -- "$root/source" 2>/dev/null | awk '{print $1}')"; is_uint "$copied_kb" || copied_kb=0
  fi
  [ -f "$root/INTRODUCTION.md" ] || cat -- "$intro" >"$root/INTRODUCTION.md"
  "$JQ" -n --arg s "$real" --arg m "$mode" --arg t "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    '{imported:true, source:$s, mode:$m, imported_at:$t,
      note:"Corypt keeps only its notes and chat tools here. The project code is ./source and Corypt never rearranges it."}' \
    >"$root/.corypt/project.json"
  "$JQ" -n --arg r "$root" --arg s "$real" --arg m "$mode" --argjson k "$copied_kb" \
    '{root:$r, source:$s, mode:$m, copied_kb:$k}' >"$dataout"
  printf 'CORYPT_ADOPT_OK: %s\n' "$root"
  printf 'CORYPT_SOURCE: %s (%s)\n' "$real" "$mode"
}

# ---- networking ----

ext_listeners() { # one line per TCP listener: port<TAB>address<TAB>process<TAB>pid
  if command -v ss >/dev/null 2>&1; then
    ss -ltnp 2>/dev/null | awk '$1 == "LISTEN" {
      addr = $4; n = split(addr, a, ":"); port = a[n]; host = substr(addr, 1, length(addr) - length(port) - 1)
      proc = ""; pid = ""
      if (match($0, /\(\("[^"]*"/)) proc = substr($0, RSTART + 3, RLENGTH - 4)
      if (match($0, /pid=[0-9]+/)) pid = substr($0, RSTART + 4, RLENGTH - 4)
      print port "\t" host "\t" proc "\t" pid }'
  elif command -v netstat >/dev/null 2>&1; then
    netstat -ltnp 2>/dev/null | awk '$6 == "LISTEN" {
      addr = $4; n = split(addr, a, ":"); port = a[n]; host = substr(addr, 1, length(addr) - length(port) - 1)
      split($7, q, "/"); print port "\t" host "\t" q[2] "\t" q[1] }'
  else
    local f hex
    for f in /proc/net/tcp /proc/net/tcp6; do
      [ -r "$f" ] || continue
      awk 'NR > 1 && $4 == "0A" { split($2, a, ":"); print a[2] }' "$f" | while read -r hex; do printf '%d\t?\t\t\n' "0x$hex"; done
    done
  fi
}

ext_corypt_ports() { cat /srv/corypt/*/.corypt/services/*.port 2>/dev/null | grep -E '^[0-9]+$' | sort -un; }

ext_cloud() {
  local v t
  v="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null)"; t="$(cat /sys/class/dmi/id/chassis_asset_tag 2>/dev/null)"
  case "$t" in *OracleCloud*) echo oracle; return ;; esac
  case "$v" in
    *Amazon*) echo aws ;; *Google*) echo gcp ;; *Microsoft*) echo azure ;; *Hetzner*) echo hetzner ;;
    *DigitalOcean*) echo digitalocean ;; *Linode*|*Akamai*) echo linode ;; *Vultr*) echo vultr ;; *Scaleway*) echo scaleway ;; *OVH*) echo ovh ;;
    *)
      if curl -s -m 1 -H 'Authorization: Bearer Oracle' http://169.254.169.254/opc/v2/instance/ 2>/dev/null | grep -q compartmentId; then echo oracle
      elif curl -s -m 1 http://169.254.169.254/hetzner/v1/metadata 2>/dev/null | grep -q instance-id; then echo hetzner
      else echo unknown; fi ;;
  esac
}

ext_ufw_active() { command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; }
ext_ufw_allows() { ufw status 2>/dev/null | grep -qE "^$1(/tcp)?[[:space:]].*ALLOW"; }
ext_fwd_active() { command -v firewall-cmd >/dev/null 2>&1 && [ "$(firewall-cmd --state 2>/dev/null)" = running ]; }
ext_fwd_allows() { firewall-cmd --query-port="$1/tcp" >/dev/null 2>&1; }
ext_ipt_blocking() {
  local r; r="$("$1" -S INPUT 2>/dev/null)" || return 1
  printf '%s\n' "$r" | grep -qE '^-P INPUT (DROP|REJECT)' && return 0
  printf '%s\n' "$r" | grep -E -- '-j (REJECT|DROP)' | grep -vqE -- '--dport|--sport|--dports|-s |-i lo|INVALID|-p icmp' && return 0
  return 1
}
ext_ipt_allows() { "$1" -S INPUT 2>/dev/null | grep -E -- '-j ACCEPT' | grep -qE -- "--dport $2( |\$)|--dports ([0-9,:]*,)?$2(,| |\$)"; }
ext_nft_input() { command -v nft >/dev/null 2>&1 && nft list chain inet filter input 2>/dev/null; }
ext_nft_blocking() { local r; r="$(ext_nft_input)" || return 1; printf '%s\n' "$r" | grep -qE 'policy drop|^[[:space:]]*(drop|reject)'; }
ext_nft_allows() { ext_nft_input | grep -E 'accept' | grep -qE "dport (\{[^}]*\b$1\b[^}]*\}|$1)( |\$)"; }
ext_layer() { "$JQ" -nc --arg l "$1" --argjson b "$2" --argjson a "$3" '{layer:$l, blocking:$b, allows:$a}'; }
ext_bool() { if "$@"; then echo true; else echo false; fi; }

ext_fw_report() { # PORT -> one JSON object per active firewall layer
  local port="$1" cmd
  if ext_ufw_active; then ext_layer ufw true "$(ext_bool ext_ufw_allows "$port")"; return; fi
  if ext_fwd_active; then ext_layer firewalld true "$(ext_bool ext_fwd_allows "$port")"; return; fi
  for cmd in iptables ip6tables; do
    command -v "$cmd" >/dev/null 2>&1 || continue
    "$cmd" -S INPUT >/dev/null 2>&1 || continue
    ext_layer "$cmd" "$(ext_bool ext_ipt_blocking "$cmd")" "$(ext_bool ext_ipt_allows "$cmd" "$port")"
  done
  if ext_nft_input >/dev/null; then ext_layer nftables "$(ext_bool ext_nft_blocking)" "$(ext_bool ext_nft_allows "$port")"; fi
}

ext_fw_state() { # PORT FILE -> writes {layers, host_open} to FILE
  ext_fw_report "$1" | "$JQ" -s '{layers:., host_open:all(.[]; (.blocking|not) or .allows)}' >"$2"
}

ext_ipt_persist() {
  if command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save >/dev/null 2>&1; then echo netfilter-persistent; return; fi
  if [ -d /etc/iptables ]; then
    [ -f /etc/iptables/rules.v4 ] && cp -p /etc/iptables/rules.v4 /etc/iptables/rules.v4.corypt.bak
    iptables-save >/etc/iptables/rules.v4 2>/dev/null
    command -v ip6tables-save >/dev/null 2>&1 && ip6tables-save >/etc/iptables/rules.v6 2>/dev/null
    echo /etc/iptables/rules.v4; return
  fi
  if [ -f /etc/sysconfig/iptables ]; then cp -p /etc/sysconfig/iptables /etc/sysconfig/iptables.corypt.bak; iptables-save >/etc/sysconfig/iptables 2>/dev/null; echo /etc/sysconfig/iptables; return; fi
  echo "not saved (no iptables persistence found; the rule is lost on reboot)"
}

ext_nft_persist() {
  [ -f /etc/nftables.conf ] || { echo "not saved (no /etc/nftables.conf)"; return; }
  if [ "$(nft list tables 2>/dev/null | grep -vc '^table inet filter$')" != 0 ]; then echo "not saved (other nft tables exist; saving could clash with them)"; return; fi
  cp -p /etc/nftables.conf /etc/nftables.conf.corypt.bak
  { printf '#!/usr/sbin/nft -f\nflush ruleset\n\n'; nft list ruleset; } >/etc/nftables.conf.corypt.new && mv /etc/nftables.conf.corypt.new /etc/nftables.conf && echo /etc/nftables.conf
}

ext_net() {
  local op="$1" dataout="$2" port
  port="$(ext_p '.payload.port // empty')"
  case "$op" in
    find) ext_port_find "$dataout" ;;
    check|open|close)
      { is_uint "$port" && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; } || ftool_die "net: a port from 1 to 65535 is needed"
      "ext_port_$op" "$port" "$dataout" ;;
    *) ftool_die "net: unknown op: $op" ;;
  esac
}

ext_port_find() {
  local dataout="$1" pref start end used reserved blk p pick="" pref_free=false tmp
  pref="$(ext_p '.payload.preferred // empty')"; start="$(ext_p '.payload.start // 8100')"; end="$(ext_p '.payload.end // 8999')"
  is_uint "$start" || start=8100; is_uint "$end" || end=8999
  [ "$end" -le 65535 ] || end=65535
  tmp="$(mktemp -d)"
  ext_listeners >"$tmp/lis"
  used="$(cut -f1 "$tmp/lis" | grep -E '^[0-9]+$' | sort -un | tr '\n' ' ')"
  reserved="$(ext_corypt_ports | tr '\n' ' ')"
  blk=" $used $reserved "
  if is_uint "$pref" && [ "$pref" -ge 1 ] && [ "$pref" -le 65535 ]; then
    case "$blk" in *" $pref "*) ;; *) pick="$pref"; pref_free=true ;; esac
  fi
  if [ -z "$pick" ]; then
    p="$start"
    while [ "$p" -le "$end" ]; do
      case "$blk" in *" $p "*) ;; *) pick="$p"; break ;; esac
      p=$(( p + 1 ))
    done
  fi
  [ -n "$pick" ] || { rm -rf "$tmp"; ftool_die "net: no free port between $start and $end"; }
  head -n 80 "$tmp/lis" | "$JQ" -R -s -c --argjson port "$pick" --arg pref "$pref" --argjson pf "$pref_free" --arg res "$reserved" \
    '{port:$port, preferred:($pref|tonumber? // null), preferred_free:$pf,
      in_use:(split("\n") | map(select(length > 0) | split("\t") | {port:(.[0]|tonumber? // .[0]), address:.[1], process:(.[2] // "")})),
      reserved_by_corypt:($res | split(" ") | map(select(length > 0) | tonumber))}' >"$dataout"
  rm -rf "$tmp"
  printf 'CORYPT_FREE_PORT: %s\n' "$pick"
}

ext_port_check() {
  local port="$1" dataout="$2" path tmp a any=false lo_only=true target="127.0.0.1" res code="" tt="" ip4="" ip6="" cloud
  path="$(ext_p '.payload.path // "/"')"; case "$path" in /*) ;; *) path="/" ;; esac
  tmp="$(mktemp -d)"
  ext_listeners | awk -F'\t' -v p="$port" '$1 == p' >"$tmp/lis"
  while IFS=$'\t' read -r _ a _ _; do
    any=true
    case "$a" in 127.*|"[::1]"|::1|localhost) ;; *) lo_only=false ;; esac
    case "$a" in 0.0.0.0|"*"|"[::]"|::|127.*|"[::1]"|::1|"["*|"?") ;; *) target="$a" ;; esac
  done <"$tmp/lis"
  if [ "$any" = true ] && [ "$lo_only" = true ] && ! cut -f2 "$tmp/lis" | grep -q '^127\.'; then target="[::1]"; fi
  if [ "$any" = true ]; then
    res="$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 6 "http://$target:$port$path" 2>/dev/null)"
    code="${res%% *}"; tt="${res#* }"
  fi
  ip4="$(curl -4 -s --max-time 5 -H "authorization: Bearer $SECRET" "$API/agent/ip" 2>/dev/null | "$JQ" -r '.ip // empty' 2>/dev/null)"
  printf '%s' "$ip4" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || ip4=""
  ip6="$(curl -6 -s --max-time 5 -H "authorization: Bearer $SECRET" "$API/agent/ip" 2>/dev/null | "$JQ" -r '.ip // empty' 2>/dev/null)"
  case "$ip6" in *:*) ;; *) ip6="" ;; esac
  cloud="$(ext_cloud)"
  ext_fw_state "$port" "$tmp/fw"
  "$JQ" -n --argjson port "$port" --argjson any "$any" --argjson lo "$lo_only" --arg target "$target" --arg code "$code" --arg tt "$tt" \
    --arg ip4 "$ip4" --arg ip6 "$ip6" --arg cloud "$cloud" --slurpfile fw "$tmp/fw" --rawfile lis "$tmp/lis" \
    '{port:$port, is_listening:$any, loopback_only:($any and $lo),
      listening:($lis | split("\n") | map(select(length > 0) | split("\t") | {address:.[1], process:(.[2] // "")})),
      local:{target:$target, http_code:$code, seconds:$tt}, firewall:$fw[0],
      public:{ipv4:$ip4, ipv6:$ip6}, cloud:$cloud}' >"$dataout"
  rm -rf "$tmp"
  printf 'CORYPT_PORT: %s listening=%s local_http=%s\n' "$port" "$any" "${code:-none}"
}

# ---- ports survive a reboot (agent 1.7.0, CORYPT_PORTS) ----
# Every port net_port_open opens is listed in /etc/corypt/ports; corypt-ports.service re-opens them after the
# firewall has loaded at boot (and again whenever nftables.service reloads), with the same code as net_port_open.
# Nothing else in the firewall configuration is rewritten. net_port_close takes a port off the list.
CORYPT_PORTS_FILE="${CORYPT_PORTS_FILE:-/etc/corypt/ports}"

ext_ports_unit() {
  local u=/etc/systemd/system/corypt-ports.service
  command -v systemctl >/dev/null 2>&1 || return 0
  if [ ! -f "$u" ]; then
    cat >"$u" <<'UNIT'
[Unit]
Description=Corypt: re-open the ports net_port_open opened, after the firewall has loaded
After=network-pre.target nftables.service netfilter-persistent.service iptables.service ufw.service firewalld.service
PartOf=nftables.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/corypt ports-restore

[Install]
WantedBy=multi-user.target nftables.service
UNIT
    systemctl daemon-reload >/dev/null 2>&1 || true
  fi
  systemctl is-enabled corypt-ports.service >/dev/null 2>&1 || systemctl enable corypt-ports.service >/dev/null 2>&1 || true
}

ext_ports_keep() { # add|remove PORT
  local op="$1" port="$2" f="$CORYPT_PORTS_FILE"
  [ -n "${CORYPT_PORTS_RESTORE:-}" ] && return 0
  mkdir -p "$(dirname "$f")" && touch "$f" || return 0
  if [ "$op" = add ]; then
    grep -qx "$port" "$f" || printf '%s\n' "$port" >>"$f"
    ext_ports_unit
  else
    { grep -vx "$port" "$f" || true; } >"$f.tmp" && mv "$f.tmp" "$f"
  fi
}

cmd_ports_restore() {
  local f="$CORYPT_PORTS_FILE" port n=0
  [ -s "$f" ] || { say "corypt ports-restore: no ports to re-open"; return 0; }
  export CORYPT_PORTS_RESTORE=1
  while read -r port; do
    is_uint "$port" || continue
    ext_port_open "$port" /dev/null || say "port $port: could not re-open"
    n=$(( n + 1 ))
  done <"$f"
  say "corypt ports-restore: $n port(s) checked"
}

ext_port_open() {
  local port="$1" dataout="$2" tmp cmd changed=false persisted="nothing changed"
  tmp="$(mktemp -d)"; : >"$tmp/act"
  ext_act() { "$JQ" -nc --arg l "$1" --arg a "$2" --argjson ok "$3" '{layer:$l, action:$a, ok:$ok}' >>"$tmp/act"; }
  ext_fw_state "$port" "$tmp/before"
  if ext_ufw_active; then
    if ext_ufw_allows "$port"; then ext_act ufw "already allowed" true
    elif ufw allow "$port/tcp" >/dev/null 2>&1; then ext_act ufw "allowed $port/tcp (saved by ufw)" true; persisted=ufw
    else ext_act ufw "ufw allow failed" false; fi
  elif ext_fwd_active; then
    if ext_fwd_allows "$port"; then ext_act firewalld "already allowed" true
    elif firewall-cmd --add-port="$port/tcp" >/dev/null 2>&1 && firewall-cmd --permanent --add-port="$port/tcp" >/dev/null 2>&1; then ext_act firewalld "added $port/tcp (runtime and permanent)" true; persisted=firewalld
    else ext_act firewalld "firewall-cmd failed" false; fi
  else
    for cmd in iptables ip6tables; do
      command -v "$cmd" >/dev/null 2>&1 || continue
      "$cmd" -S INPUT >/dev/null 2>&1 || continue
      if ! ext_ipt_blocking "$cmd"; then ext_act "$cmd" "not blocking, nothing to do" true; continue; fi
      if ext_ipt_allows "$cmd" "$port"; then ext_act "$cmd" "already allowed" true; continue; fi
      if "$cmd" -I INPUT 1 -p tcp --dport "$port" -m comment --comment corypt -j ACCEPT 2>/dev/null \
         || "$cmd" -I INPUT 1 -p tcp --dport "$port" -j ACCEPT 2>/dev/null; then
        ext_act "$cmd" "inserted ACCEPT for tcp $port at the top of INPUT" true; changed=true
      else ext_act "$cmd" "could not insert the rule" false; fi
    done
    [ "$changed" = true ] && persisted="$(ext_ipt_persist)"
    if ext_nft_input >/dev/null; then
      if ! ext_nft_blocking; then ext_act nftables "not blocking, nothing to do" true
      elif ext_nft_allows "$port"; then ext_act nftables "already allowed" true
      elif nft insert rule inet filter input tcp dport "$port" accept comment '"corypt"' 2>/dev/null; then
        ext_act nftables "inserted accept for tcp $port" true; persisted="$persisted; nft: $(ext_nft_persist)"
      else ext_act nftables "could not insert the rule" false; fi
    fi
  fi
  ext_fw_state "$port" "$tmp/after"
  if [ "$("$JQ" -r '.host_open' "$tmp/after")" = true ]; then   # CORYPT_PORTS: remembered, re-opened at every boot
    ext_ports_keep add "$port"
    case "$persisted" in ufw|firewalld) ;; *) persisted="yes, by corypt-ports.service, which re-opens it at every boot and firewall reload (firewall files: $persisted)" ;; esac
  fi
  "$JQ" -n --argjson port "$port" --slurpfile b "$tmp/before" --slurpfile a "$tmp/after" --slurpfile act "$tmp/act" \
    --arg persisted "$persisted" --arg cloud "$(ext_cloud)" \
    '{port:$port, before:$b[0], actions:$act, persisted:$persisted, after:$a[0], host_open_after:$a[0].host_open, cloud:$cloud}' >"$dataout"
  printf 'CORYPT_PORT_OPEN: %s host_open=%s\n' "$port" "$("$JQ" -r '.host_open' "$tmp/after")"
  rm -rf "$tmp"
}

ext_port_close() {
  local port="$1" dataout="$2" tmp cmd n changed=false h
  tmp="$(mktemp -d)"; : >"$tmp/act"
  ext_act() { "$JQ" -nc --arg l "$1" --arg a "$2" --argjson ok "$3" '{layer:$l, action:$a, ok:$ok}' >>"$tmp/act"; }
  if ext_ufw_active; then
    if ext_ufw_allows "$port"; then ufw delete allow "$port/tcp" >/dev/null 2>&1 && ext_act ufw "removed allow $port/tcp" true || ext_act ufw "ufw delete failed" false
    else ext_act ufw "was not allowed" true; fi
  elif ext_fwd_active; then
    firewall-cmd --remove-port="$port/tcp" >/dev/null 2>&1; firewall-cmd --permanent --remove-port="$port/tcp" >/dev/null 2>&1
    ext_act firewalld "removed $port/tcp" true
  else
    for cmd in iptables ip6tables; do
      command -v "$cmd" >/dev/null 2>&1 || continue
      n=0
      while [ "$n" -lt 20 ] && "$cmd" -D INPUT -p tcp --dport "$port" -m comment --comment corypt -j ACCEPT 2>/dev/null; do n=$(( n + 1 )); done
      [ "$n" -gt 0 ] && changed=true
      ext_act "$cmd" "removed $n Corypt rule(s) for tcp $port" true
    done
    [ "$changed" = true ] && ext_ipt_persist >/dev/null
    if ext_nft_input >/dev/null; then
      n=0
      for h in $(nft -a list chain inet filter input 2>/dev/null | grep -E "dport $port accept comment \"corypt\"" | sed -nE 's/.*# handle ([0-9]+).*/\1/p'); do
        nft delete rule inet filter input handle "$h" 2>/dev/null && n=$(( n + 1 ))
      done
      [ "$n" -gt 0 ] && ext_nft_persist >/dev/null
      ext_act nftables "removed $n Corypt rule(s) for tcp $port" true
    fi
  fi
  ext_ports_keep remove "$port"   # CORYPT_PORTS
  ext_fw_state "$port" "$tmp/after"
  "$JQ" -n --argjson port "$port" --slurpfile a "$tmp/after" --slurpfile act "$tmp/act" \
    '{port:$port, actions:$act, after:$a[0], host_open_after:$a[0].host_open}' >"$dataout"
  printf 'CORYPT_PORT_CLOSE: %s\n' "$port"
  rm -rf "$tmp"
}

# ---- services: one systemd unit per long-running program of a project ----
# Unit name: corypt-<slug>_<name>. Slugs never contain "_" and names never
# do either, so one project's units can never be mistaken for another's.

ext_service_up() {
  local dataout="$1" root name port wd slug unit script envf portf i active=false listening=false restarts=0 tmp
  root="$(ext_p '.payload.root // ""')"; name="$(ext_p '.payload.name // ""')"
  port="$(ext_p '.payload.port // empty')"; wd="$(ext_p '.payload.workdir // empty')"
  case "$root" in /srv/corypt/*) ;; *) ftool_die "service_up: the project folder must be under /srv/corypt/" ;; esac
  slug="${root#/srv/corypt/}"
  printf '%s' "$slug" | grep -Eq '^[a-z0-9][a-z0-9-]{0,19}$' || ftool_die "service_up: bad project folder: $root"
  printf '%s' "$name" | grep -Eq '^[a-z0-9][a-z0-9-]{0,29}$' || ftool_die "service_up: name must be lowercase letters, digits and dashes (max 30)"
  [ -d "$root" ] || ftool_die "service_up: project folder not found: $root"
  [ "$(service_kind)" = systemd ] || ftool_die "service_up: this server has no systemd. Start the program with exec_start (nohup ... &) and tell the user it will not come back after a reboot."
  unit="corypt-${slug}_$name"
  mkdir -p "$root/.corypt/services"
  script="$root/.corypt/services/$name.sh"; envf="$root/.corypt/services/$name.env"; portf="$root/.corypt/services/$name.port"
  { printf '#!/usr/bin/env bash\n# Run by Corypt as %s.service. Change it with service_up, not by hand.\n' "$unit"
    printf '%s' "$EXT_JOB" | "$JQ" -j '.payload.command // ""'; printf '\n'; } >"$script"
  chmod 755 "$script"
  ( umask 077; printf '%s' "$EXT_JOB" | "$JQ" -r '.payload.env // {} | to_entries[] | "\(.key)=\(.value|tostring)"' >"$envf" )
  if is_uint "$port"; then printf 'PORT=%s\n' "$port" >>"$envf"; printf '%s\n' "$port" >"$portf"; else rm -f "$portf"; port=""; fi
  if [ -z "$wd" ]; then if [ -d "$root/source" ]; then wd="$(readlink -f -- "$root/source")"; else wd="$root"; fi; fi
  [ -d "$wd" ] || ftool_die "service_up: working directory not found: $wd"
  cat >"/etc/systemd/system/$unit.service" <<UNIT
[Unit]
Description=Corypt project $slug: $name
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=${wd//%/%%}
EnvironmentFile=-$envf
Environment=CORYPT_PROJECT=$root
ExecStart=/bin/bash $script
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
UNIT
  systemctl daemon-reload
  systemctl enable "$unit" >/dev/null 2>&1
  systemctl restart "$unit" >/dev/null 2>&1
  for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
    sleep 1
    if [ -n "$port" ]; then ext_listeners | cut -f1 | grep -qx "$port" && { listening=true; break; }
    elif [ "$i" -ge 4 ]; then break; fi
  done
  systemctl is-active --quiet "$unit" && active=true
  restarts="$(systemctl show -p NRestarts --value "$unit" 2>/dev/null)"; is_uint "$restarts" || restarts=0
  tmp="$(mktemp)"
  journalctl -u "$unit" -n 30 --no-pager -o cat 2>/dev/null | tail -c 4000 >"$tmp"
  "$JQ" -n --arg u "$unit" --argjson act "$active" --argjson lis "$listening" --arg port "$port" --argjson r "$restarts" \
    --arg wd "$wd" --arg s "$script" --rawfile logs "$tmp" \
    '{unit:$u, active:$act, listening:$lis, port:($port|tonumber? // null), restarts:$r, workdir:$wd, script:$s, logs:$logs}' >"$dataout"
  rm -f "$tmp"
  printf 'CORYPT_SERVICE: %s active=%s listening=%s restarts=%s\n' "$unit" "$active" "$listening" "$restarts"
  [ "$active" = true ] && [ "$restarts" = 0 ] && { [ -z "$port" ] || [ "$listening" = true ]; } && return 0
  printf 'service_up: %s is not healthy (active=%s, restarts=%s, listening on %s=%s). Its last log lines are in the result.\n' "$unit" "$active" "$restarts" "${port:-n/a}" "$listening" >&2
  return 1
}

ext_service_down() {
  local root="${1:-}" name="${2:-}" dataout="${3:-}" slug u stopped=""
  case "$root" in /srv/corypt/*) ;; *) ftool_die "service_down: the project folder must be under /srv/corypt/" ;; esac
  slug="${root#/srv/corypt/}"
  printf '%s' "$slug" | grep -Eq '^[a-z0-9][a-z0-9-]{0,19}$' || ftool_die "service_down: bad project folder: $root"
  if [ -n "$name" ]; then
    printf '%s' "$name" | grep -Eq '^[a-z0-9][a-z0-9-]{0,29}$' || ftool_die "service_down: bad service name"
  fi
  for u in /etc/systemd/system/corypt-"${slug}"_*.service; do
    [ -f "$u" ] || continue
    u="$(basename "$u" .service)"
    [ -n "$name" ] && [ "$u" != "corypt-${slug}_$name" ] && continue
    systemctl disable --now "$u" >/dev/null 2>&1
    rm -f "/etc/systemd/system/$u.service" "$root/.corypt/services/${u#corypt-"${slug}"_}.port"
    stopped="$stopped $u"
  done
  systemctl daemon-reload >/dev/null 2>&1
  printf '%s' "$stopped" | "$JQ" -R -c '{stopped:(split(" ") | map(select(length > 0)))}' >"$dataout"
  printf 'CORYPT_SERVICES_STOPPED:%s\n' "${stopped:- none}"
}

# ---------------------------------------------------------------------
# Help and dispatch
# ---------------------------------------------------------------------

usage() {
  cat <<'HELP'
Corypt agent — connects this server to corypt.com so your AI can build on it.

Set up (as root):
  ./corypt.sh install CODE      connect this server; CODE comes from corypt.com, Servers

Manage:
  corypt status                 is it connected and running?
  corypt logs [-f]              recent activity (-f keeps following)
  corypt restart                restart the service
  corypt update                 install the newest agent
  corypt uninstall              remove the agent, service and configuration
  corypt version                show the version

File tools (used by AI agents for precise edits):
  corypt read  FILE [START] [END]       show lines, each wrapped in <CORYPT_lineN> tags
  corypt write FILE START END           replace lines START..END (both included);
                                        the replacement comes on stdin between
                                        <CORYPT_REPLACE_WITH> and </CORYPT_REPLACE_WITH>,
                                        each tag on its own line
  corypt grep  PATTERN FILE [CONTEXT]   find lines with their numbers
  corypt lines FILE                     count lines
HELP
}

needs_root() {
  case "$1" in install|run|start-bg|stop-bg|status|logs|restart|update|uninstall|ports-restore) return 0 ;; *) return 1 ;; esac
}

main() {
  local sub="${1:-help}"
  [ $# -gt 0 ] && shift
  if needs_root "$sub" && [ "$(id -u)" -ne 0 ]; then
    if command -v sudo >/dev/null 2>&1 && [ -f "$SELF" ]; then
      exec sudo -E bash "$SELF" "$sub" "$@"
    fi
    die "this needs root. Run it as root, or install sudo."
  fi
  case "$sub" in
    read|write|grep|lines) pick_jq >/dev/null 2>&1 ;;
    install) ;;
    *) pick_jq >/dev/null 2>&1 || true ;;
  esac
  case "$sub" in
    install)          cmd_install "$@" ;;
    run)              cmd_run ;;
    start-bg)         cmd_start_bg ;;
    stop-bg)          cmd_stop_bg ;;
    status)           cmd_status ;;
    logs)             cmd_logs "$@" ;;
    restart)          cmd_restart ;;
    update)           cmd_update ;;
    ports-restore)    cmd_ports_restore ;;   # CORYPT_PORTS
    uninstall)        cmd_uninstall ;;
    read)             cmd_read "$@" ;;
    write)            cmd_write "$@" ;;
    grep)             cmd_grep "$@" ;;
    lines)            cmd_lines "$@" ;;
    version|-v|--version) say "corypt $CORYPT_VERSION" ;;
    help|-h|--help)   usage ;;
    *)                die "unknown command: $sub   (see: corypt help)" ;;
  esac
}

# Old-style names still work: corypt_read, corypt_write, corypt_grep.
case "$(basename "$0")" in
  corypt_read)  main read "$@" ;;
  corypt_write) main write "$@" ;;
  corypt_grep)  main grep "$@" ;;
  *)            main "$@" ;;
esac
