---
name: aicommander-install-qnap
description: Install the AI Commander agent on a QNAP NAS (QTS / QuTS hero). Use this whenever the user wants AI Commander, a remote agent, or remote shell access ON a QNAP NAS, or when the normal Linux installer fails there with "curl (23) Failure writing output to destination", "npm command not found", or the agent dies with "Illegal instruction". QNAP needs a different procedure than a normal Linux server — the stock installer cannot work there.
license: See https://aicommander.dev
---

# AI Commander — installing the agent on a QNAP NAS

QNAP's QTS is not a normal Linux distribution, and the standard installer
(`https://aicommander.dev/install`) **cannot** work on it. This skill gives you a
procedure that does, plus the platform traps that cause silent failures.

NAS boxes are excellent hosts for the agent: they run 24/7 and come back on their
own after a power cut, unlike a laptop or desktop.

## Why the normal paths fail

Verified on TS-X73A (QTS 5.2.9) and TS-X53D (QTS 5.2.4).

**The native installer** dies with `curl: (23) Failure writing output to
destination`. That message suggests a network problem; it is actually **out of
disk space**. The agent binary is ~95 MB while `/tmp` is a 64 MB tmpfs. Worse,
the installer targets `/usr/local/bin`, which on QTS sits on a 400 MB **ramdisk
that is rebuilt on every boot** — so even a successful install would vanish at
the next restart. QTS also has no systemd, so the service unit it writes is
meaningless.

**The npm path** fails with `npm: command not found`. There is no npm and no node
in `PATH`; the Node runtime you may find inside `/share/*/.qpkg/QDMS/bin/node`
belongs to QNAP's media server — do not use it.

## Rule 0 — everything here runs as root. There is no second option.

On QTS the account `admin` **is uid 0**. Install as root, start as root, run every
CLI subcommand as root. Do not improvise a reduced-privilege variant; each part of
this procedure needs uid 0 for a different reason, and a mixed install fails on
the first reboot in a way that is hard to diagnose:

- `/etc/config/crontab` is *root's* crontab, so the restart entry (step 5) runs
  the agent **as root** no matter who started it. The identity directory is
  `0700`; once a root-started agent rewrites `device.json` / `session.json` a
  user-started agent cannot read them, so it mints a **new session code** and
  every linked account is lost.
- `status --reveal` is how you read the session code back after startup (step 4).
  It requires uid 0, and the runtime state it reads lives under `/var/run`, which
  only root can write. The code is *also* persisted in
  `$D/config/session.json` (`sessionCode`, alongside the agent token) — mode
  `0600`, root-only, and that is exactly why the identity directory must never
  become readable by anyone else.
- The install directory — and every directory on the path to it — must be
  root-owned and un-replaceable by other users (Rule 4), or the root cron entry
  ends up executing a script any NAS user can put there.

State the trade-off to the user before you start: **the agent runs as root, so the
session code is root access to this NAS** — the same model the Linux installer
documents. If they will not give you root, stop and say so.

```sh
[ "$(id -u)" = "0" ] || { echo "Not root — see Rule 0, do not continue" >&2; exit 1; }
```

## Rule 1 — the only durable locations, and the preamble every block repeats

Only `/share/**` and `/etc/config/**` survive a reboot. Everything else —
`/etc`, `/var/run`, `/usr/local`, `/tmp` — is volatile.

Never hardcode `/share/CACHEDEV1_DATA`; QuTS hero uses ZFS pools with different
names. Resolve everything with `getcfg`:

```sh
# Confirm this really is a QNAP
[ -f /etc/config/uLinux.conf ] && [ -x /sbin/getcfg ] && echo "QNAP"

/sbin/getcfg System Model                                        # e.g. TS-X53D
/sbin/getcfg System Version                                      # e.g. 5.2.4
```

**Every executable block in this document is self-contained: it re-derives `VOL`
and `D` itself and must be run as a whole.** Nothing carries over between blocks —
`export`s, variables, shell functions — because you are most likely driving this
over SSH or a remote-exec tool that hands each block a **brand-new shell**. This
is the preamble; every block that touches the install opens with it, verbatim,
and it is load-bearing:

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
```

Do not "optimise" it away on the assumption that an earlier block already set
`$D`. With `D` empty, `"$D/keepalive.sh"` is `/keepalive.sh` and `"$D/config"` is
`/config` — the ramdisk, at the filesystem root. Some of those commands fail
loudly; the cron entry in step 5 would succeed, report nothing, and quietly
destroy the reboot survival this whole procedure exists to provide. The `:?`
guard is the cheap insurance that the block aborts instead.

## Rule 2 — set AICOMMANDER_CONFIG_DIR, or lose the pairing

The agent stores its device identity and **session code** in
`/etc/aicommander-agent` by default. On QTS that is the ramdisk. After a reboot
the agent returns with a **brand-new session code** and every linked account is
gone.

Always start the agent — and every CLI subcommand — with this export, in the same
shell, and only after the Rule 1 preamble has resolved `$D`. Never on its own:
with `$D` empty it points the store at `/config`, which is absolute, writable by
root and on the ramdisk — so the agent accepts it and you are back to a new
session code on every boot.

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"

export AICOMMANDER_CONFIG_DIR="$D/config"
```

If you forget it on a CLI call, that call talks to a *different* device identity
than the running service. The agent warns when it mints a fresh identity for this
reason; treat that warning as a red flag, not noise.

Two things the agent does for you once the variable is set: an unusable value (not
absolute, or not writable) is a **fatal startup error** rather than a silent
fallback to the ramdisk, and a device identity already registered under `/etc` or
`~/.config` is **adopted** into the new directory on the next start — so turning
this on does not un-link a NAS that is already paired.

Agents **older than 1.0.37 ignore the variable entirely**, and are not the
baseline x86-64 build either. Step 3 checks this and refuses to install
such a build; do not work around that check.

### `AICOMMANDER_SERVICE=1` goes with it — everywhere the agent is started

Pointing the store at durable storage is worthless if a failed write there is
ignored, and by default it is: `saveSession` (`session-store.ts`) only *enforces*
a successful write under `isStrictCredentialStorage()`
(`credential-storage.ts`), which is true for `AICOMMANDER_SERVICE=1`,
`NODE_ENV=production`, or systemd's `INVOCATION_ID` / `JOURNAL_STREAM`. **QTS
sets none of those** — there is no systemd here — so without this variable an
agent whose `session.json` write fails (read-only volume, full disk, a `config/`
that is not writable) keeps running happily on a session code that exists only in
its memory. It works, you hand the code to the user, and it is gone at the next
reboot: the exact failure this procedure exists to prevent, with no error
anywhere. With the variable set the agent fails closed and says so in
`$D/agent.log`.

So export it in **every** shell that starts the agent — step 4, the generated
`keepalive.sh`, and any restart you improvise. This is a service install in
everything but name, and it must fail closed the way a real service install
does. It does not change what `status --reveal` prints, and it is
not needed for read-only CLI calls.

## Rule 3 — BusyBox, not GNU

There is **no `nohup`** (use `setsid`) and **no `pkill`/`pgrep`** — a `pkill` that
appears to work actually exits 127 and leaves the agent running, which is how you
end up with orphaned agents holding live session codes.

`ps` is no substitute: BusyBox truncates its command column to the terminal width
(80 with no tty), so a full `/share/…` path never appears in the output, and the
path handed to `grep` would be a *regex* matched against every process. Find the
agent through `/proc` instead. `agent_pids` below is the canonical **liveness**
check and the block is the canonical **stop** — every other block that needs
either pastes this same function in (Rule 1: nothing carries over):

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"

agent_pids() {
  for p in /proc/[0-9]*; do
    e="$(readlink "$p/exe" 2>/dev/null)" || continue
    # "(deleted)" is what an in-flight upgrade leaves behind.
    case "$e" in "$D/agent.bin"|"$D/agent.bin (deleted)") ;; *) continue ;; esac
    # The supervisor and its worker share this binary and both carry a bare `run`;
    # a concurrent `status` / `change-code` CLI does not and must not be killed.
    tr '\0' '\n' < "$p/cmdline" 2>/dev/null | grep -qx run || continue
    echo "${p#/proc/}"
  done
}

pids="$(agent_pids)"
if [ -z "$pids" ]; then
  echo "agent not running"
else
  # Word splitting is intended — one PID per line.
  kill $pids 2>/dev/null
  # SIGTERM is asynchronous: the agent still has to run its shutdown handler.
  # Callers act on "it is gone", never on "the kill returned", so wait for it.
  i=0
  while [ "$i" -lt 10 ]; do
    [ -z "$(agent_pids)" ] && break
    sleep 1
    i=$((i + 1))
  done
  pids="$(agent_pids)"
  if [ -n "$pids" ]; then
    kill -9 $pids 2>/dev/null
    sleep 1
  fi
  [ -z "$(agent_pids)" ] \
    || { echo "AGENT STILL RUNNING — do not upgrade, restart or reboot; investigate first" >&2; exit 1; }
  echo "agent stopped"
fi
```

It kills **every** match on purpose: a healthy agent is two processes, and killing
only the worker just makes the supervisor respawn it. And it does not return
success until `/proc` says the process is gone: an upgrade that renames the binary
under a live agent, or a start issued while the old one is still up, leaves two
agents holding two session codes — the exact orphan this rule exists to prevent.

`awk`, `sed`, `mktemp` and `adduser` are reduced BusyBox variants. `useradd` does
not exist at all, so the agent's `install` subcommand (which creates a sandbox user
and a systemd unit) is unusable — do not call it. Keep every script POSIX `sh`.

## Rule 4 — refuse an install path another local user can replace

Everything under `$D` is executed **by root**: the agent binary in steps 3 and 4,
and `keepalive.sh` from root's crontab every 5 minutes (step 5). So `0700` on `$D`
answers only half the question. The other half is whether a local user can
*become* the file root executes — and they can, without ever touching `$D`
itself, if any directory on the path to it is writable by them and has no sticky
bit: they rename `$D` aside, put their own directory (and their own
`keepalive.sh`) in its place, and root runs it on the next tick. That is local
privilege escalation to root, delivered by this install procedure. NAS shares are
commonly `drwxrwxrwx`, so this is the normal case, not a corner case.

A warning is not enough for that. Every block that creates `$D`, executes
something out of it, or installs the cron entry carries this check and **refuses**
to continue when it fails — including the generated `keepalive.sh`, which re-runs
it as root every 5 minutes and so also catches tampering that happens later:

```sh
# Refuses (never warns): root executes what is under $D, so every component of
# the path to it must be un-replaceable by other local users.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}
```

Two notes on why it is written this way. `-O` asks "owned by the user running
this", which is root (Rule 0) — do not compare owner *names*, because on QTS uid 0
is the `admin` account, not `root`. And the sticky bit is the cheap remedy for a
shared parent: `chmod +t "$VOL"` keeps everyone's write access and only stops
users from renaming or deleting entries they do not own, exactly as `/tmp` does.
Step 1 sets it where the parent is root-owned; every later block only verifies,
and stops if it is still unsafe.

## The install — direct, ~5 minutes

No extra tooling, and it keeps the release's cryptographic verification. This is
the only supported way to put the agent on a QNAP NAS — there is no QPKG package,
and App Center is not involved.

### 1. Download

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
[ -w "$VOL" ] || { echo "Data volume not writable: '$VOL'" >&2; exit 1; }

# Rule 4 — root executes what is under $D, so no other local user may be able to
# replace it. Verbatim from Rule 4; every block that touches $D repeats it.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}

# Establish the safe path before creating anything, where it can be established
# at all: a root-owned parent that other users may write to needs only the sticky
# bit to stop them renaming $D away. That is a strict tightening (everyone keeps
# write access; nobody can touch entries they do not own), it is reversible with
# `chmod -t`, and it is the only change this procedure makes outside $D — so tell
# the user it happened. A parent that is NOT root-owned cannot be fixed from
# here; aic_safe_path below refuses on it.
p="$D"
while [ "$p" != "/" ]; do
  p="$(dirname "$p")"
  m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)" || break
  case "$m" in ?????w????|????????w?)
    if [ -O "$p" ] && [ ! -k "$p" ]; then
      chmod +t "$p" && echo "NOTE: added the sticky bit to $p ($m) so other local users cannot rename $D away (Rule 4); undo with: chmod -t $p"
    fi ;;
  esac
done

# Check the PARENT before creating anything in it: `mkdir -p` and `chown -R`
# follow symlinks, so a $D planted by another user would have us build (and
# chown) their tree. With the parent verified, only root can have created $D —
# and the check below re-verifies $D itself once it exists.
aic_safe_path "$(dirname "$D")"

# root-owned 0700. The session code lives in config/, and step 5 has root's cron
# execute keepalive.sh from here every 5 minutes — the data volume itself is
# typically world-writable (drwxrwxrwx), so anything looser hands every NAS/SMB
# user both the credential and a root code-execution timer.
mkdir -p "$D/config" || exit 1
chown -R 0:0 "$D" && chmod 700 "$D" "$D/config" \
  || { echo "Cannot secure $D" >&2; exit 1; }

# Nothing is downloaded until the whole path is safe — $D included.
aic_safe_path "$D"

VER="$(curl -fsSL https://aicommander.dev/dist/latest | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([0-9.]*\)".*/\1/p')"
# A failed curl leaves VER empty and the endpoint answers "version":null before a
# release exists — both would build the URL /dist/v//agent-… and "download" an
# error page. Nothing below may run on an unvalidated version.
echo "$VER" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
  || { echo "Bad release version: '$VER' — aborting" >&2; exit 1; }

case "$(uname -m)" in
  x86_64) ARCH=linux-x64 ;;
  aarch64|arm64) ARCH=linux-arm64 ;;
  *) echo "Unsupported architecture: $(uname -m) — 64-bit only" >&2; exit 1 ;;
esac
U="https://aicommander.dev/dist/v/$VER/agent-$ARCH"

# Staged as agent.new, NOT written over $D/agent.bin: on an upgrade the running
# agent and the cron job would otherwise pick up an unverified binary before step
# 2 has a chance to reject it. Same volume, so step 3 finishes with a rename.
umask 077
curl -fsSL "$U"        -o "$D/agent.new"    || { echo "Download failed: $U" >&2; exit 1; }
curl -fsSL "$U.sha256" -o "$D/agent.sha256" || { echo "Download failed: $U.sha256" >&2; exit 1; }
curl -fsSL "$U.sig"    -o "$D/agent.sig"    || { echo "Download failed: $U.sig" >&2; exit 1; }
```

### 2. Verify before running it

It executes as root. Both checks depend on tooling QTS is not contractually
obliged to ship — the boxes tested here have `/bin/sha256sum` and OpenSSL 3.0.19,
but firmware varies — so each one is capability-gated first. That gate is not
ceremony: `pkeyutl -rawin` only exists from OpenSSL 3.0, and running it bare on an
older build exits non-zero on the *unknown option*, which is indistinguishable
from a bad signature. Without the gate the skill would tell the user their
download was tampered with when in fact their firmware is simply too old.

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"

EXP="$(awk '{print $1}' "$D/agent.sha256")"
if command -v sha256sum >/dev/null 2>&1; then
  ACT="$(sha256sum "$D/agent.new" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
  ACT="$(shasum -a 256 "$D/agent.new" | awk '{print $1}')"
else
  echo "No sha256 tool (sha256sum/shasum) available to verify the download." >&2; exit 1
fi
# Both halves must be non-empty: a truncated or error-page .sha256 makes EXP empty
# and "" = "" would otherwise report a successful verification.
[ -n "$EXP" ] && [ -n "$ACT" ] || { echo "Empty checksum — do not run this binary" >&2; exit 1; }
[ "$EXP" = "$ACT" ] || { echo "CHECKSUM MISMATCH — do not run this binary" >&2; exit 1; }

# The signing key is PINNED HERE, never downloaded. Fetching it from the same
# origin that served the binary and the signature would prove nothing: whoever can
# serve you a tampered binary can serve a matching key alongside it. This is byte
# for byte the key web/install embeds; its SPKI SHA-256 is
# 2d76d381fc8ed38e7dfb53882e14b2980ee105e0b49ff31cf55403e19e648407, published in
# the project README for out-of-band comparison. Never substitute another key.
cat > "$D/agent.pub" <<'PUBKEY'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAcqNx01NvglpKTsF60Yij5LuoIHgXJ/SUoQysfU2eyRw=
-----END PUBLIC KEY-----
PUBKEY

[ -s "$D/agent.sig" ] || { echo "Empty signature — do not run this binary" >&2; exit 1; }

# Capability gate, same as web/install. Ed25519
# detached verification needs `pkeyutl -rawin` (OpenSSL 3.0+); LibreSSL cannot do
# it at all. Report THAT, distinctly — never as a signature failure.
command -v openssl >/dev/null 2>&1 \
  || { echo "OpenSSL 3.x is required to verify the binary signature but was not found." >&2; exit 1; }
case "$(openssl version 2>/dev/null)" in
  *LibreSSL*) echo "Installed openssl cannot verify Ed25519 signatures (LibreSSL or missing pkeyutl -rawin) — refusing to install. Install OpenSSL 3.x." >&2; exit 1 ;;
esac
openssl pkeyutl -help 2>&1 | grep -q -e -rawin \
  || { echo "Installed openssl cannot verify Ed25519 signatures (LibreSSL or missing pkeyutl -rawin) — refusing to install. Install OpenSSL 3.x." >&2; exit 1; }

openssl pkeyutl -verify -pubin -inkey "$D/agent.pub" -rawin \
  -in "$D/agent.new" -sigfile "$D/agent.sig" \
  || { echo "SIGNATURE INVALID — do not run this binary" >&2; exit 1; }

chmod 700 "$D/agent.new"

# Delete the artifacts now that they have served their purpose. If the binary is
# ever replaced (see the AVX2 note) these would no longer match it, and re-running
# the block above would report a MISMATCH on a perfectly good install.
rm -f "$D/agent.sha256" "$D/agent.sig" "$D/agent.pub"
```

### 3. Smoke-test and version-gate it, then move it into place

Two failures can only be found by running the binary, and both are silent once the
agent is backgrounded:

- a CPU without AVX2 kills it with **`Illegal instruction`** (exit 132);
- an agent **older than 1.0.37** ignores `AICOMMANDER_CONFIG_DIR` (Rule 2) and is
  not the baseline x86-64 build — installing one reproduces exactly the two
  failures this whole procedure exists to prevent, with no error anywhere.

The binary is verified but not yet live, so this is the last safe moment:

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
[ -x "$D/agent.new" ] || { echo "No staged binary at $D/agent.new — run steps 1-2 first" >&2; exit 1; }

# Rule 4, verify-only: this block RUNS the staged binary as root. Re-checked
# rather than assumed from step 1 — blocks are minutes or hours apart.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}
aic_safe_path "$D"

# Single source of truth for the minimum supported agent version; CI greps this
# exact line. Keep it alone on its own line, in this form.
MIN=1.0.37
V="$("$D/agent.new" --version 2>&1 | head -1 | tr -d '\r')"
echo "$V" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
  || { echo "AGENT FAILED TO START (output: '$V') — see Troubleshooting" >&2; exit 1; }

# BusyBox has no `sort -V`, so compare field by field.
i=1
while [ "$i" -le 3 ]; do
  a="$(echo "$V"   | cut -d. -f"$i")"
  b="$(echo "$MIN" | cut -d. -f"$i")"
  [ "$a" -gt "$b" ] && break
  [ "$a" -lt "$b" ] && { echo "Agent $V is older than $MIN — it would ignore AICOMMANDER_CONFIG_DIR and lose the pairing on every reboot. Stop here." >&2; exit 1; }
  i=$((i + 1))
done

# Refuse to swap the binary under a live agent. `mv` would succeed, the running
# process would keep executing the old (now deleted) inode, and step 4 would
# start a SECOND agent beside it — two agents, two session codes (Rule 3).
agent_pids() {
  for p in /proc/[0-9]*; do
    e="$(readlink "$p/exe" 2>/dev/null)" || continue
    case "$e" in "$D/agent.bin"|"$D/agent.bin (deleted)") ;; *) continue ;; esac
    tr '\0' '\n' < "$p/cmdline" 2>/dev/null | grep -qx run || continue
    echo "${p#/proc/}"
  done
}
[ -z "$(agent_pids)" ] \
  || { echo "Agent still running — run the Rule 3 stop command first, then re-run this block" >&2; exit 1; }

mv -f "$D/agent.new" "$D/agent.bin"     # only now is it the live binary
```

If the version gate trips, the published release does not yet contain what this
procedure depends on. Say that plainly, remove `$D/agent.new`, and stop — there is
no supported workaround.

Upgrading an existing install? Run the **Rule 3** stop command first and wait for
`agent stopped` (or `agent not running`) — those are the only outputs that mean
no process is left; on `AGENT STILL RUNNING` do not continue. Then run this block
and start the agent again with step 4.

### 4. Start it

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
[ -x "$D/agent.bin" ] || { echo "No agent at $D/agent.bin — run steps 1-3 first" >&2; exit 1; }

# Rule 4, verify-only: this block launches a ROOT process out of $D. Re-run this
# whole block (not just the setsid line) for every restart, so the check happens
# every time.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}
aic_safe_path "$D"

# Same /proc discovery as Rule 3 — this block needs it three times.
agent_pids() {
  for p in /proc/[0-9]*; do
    e="$(readlink "$p/exe" 2>/dev/null)" || continue
    case "$e" in "$D/agent.bin"|"$D/agent.bin (deleted)") ;; *) continue ;; esac
    tr '\0' '\n' < "$p/cmdline" 2>/dev/null | grep -qx run || continue
    echo "${p#/proc/}"
  done
}
[ -z "$(agent_pids)" ] \
  || { echo "Agent already running — stop it with the Rule 3 command first" >&2; exit 1; }

export AICOMMANDER_CONFIG_DIR="$D/config"

# This IS a service install, so say so (Rule 2 — do not drop this line).
# Without it a session.json the agent could not write is SILENTLY IGNORED and the
# agent runs on a session code that exists only in memory, which is the exact
# reboot failure this whole procedure exists to prevent.
export AICOMMANDER_SERVICE=1
umask 077

# The log must exist as 0600 BEFORE the redirect: `>>` leaves an older 0644 file
# from a previous install exactly as it was, and $D may be exported over SMB.
: >> "$D/agent.log" && chmod 600 "$D/agent.log" || exit 1

# state.json is written only after the relay answers, and it is removed only by
# the agent's own SIGINT/SIGTERM handler — a crashed agent leaves a stale one
# behind for the rest of the boot. Clearing it now (nothing is running, checked
# above) is what makes the poll below mean "this start registered". The path is
# fixed for every agent on the box — one more reason never to run two agent
# installs side by side.
rm -f /var/run/aicommander-agent/state.json

# STDOUT IS DISCARDED ON PURPOSE. Outside systemd the agent prints the FULL
# session code on stdout; appending that to a file would persist a root-exec
# credential on a shared volume. Stderr is what makes the log worth keeping —
# registration failures, crashes, `Illegal instruction` — and never carries the
# code. setsid detaches from the terminal; BusyBox has no nohup.
setsid "$D/agent.bin" run < /dev/null > /dev/null 2>> "$D/agent.log" &

# Two guards, because right after `&` even a binary that dies instantly is still
# alive: wait for it to appear, then re-check after a settle. `Illegal
# instruction` on a pre-AVX2 CPU lands milliseconds in.
i=0; up=""
while [ "$i" -lt 10 ]; do
  sleep 1
  i=$((i + 1))
  [ -n "$(agent_pids)" ] || continue
  sleep 2
  [ -n "$(agent_pids)" ] && up=1
  break
done
[ -n "$up" ] || { echo "AGENT NOT RUNNING after start — read $D/agent.log" >&2; exit 1; }

# Registration is a network round-trip. Poll rather than sleeping a fixed 10s: a
# busy NAS or a slow link takes longer, and a fixed wait would report a healthy
# agent as broken.
i=0
while [ "$i" -lt 30 ]; do
  [ -s /var/run/aicommander-agent/state.json ] && break
  [ -n "$(agent_pids)" ] || { echo "AGENT DIED WHILE REGISTERING — read $D/agent.log" >&2; exit 1; }
  sleep 2
  i=$((i + 1))
done
[ -s /var/run/aicommander-agent/state.json ] \
  || { echo "No session code after 60s — read $D/agent.log" >&2; exit 1; }

# Read the code back from the running agent (root only, current boot only):
"$D/agent.bin" status --reveal
```

Ignore the `State` and `Enabled` lines in that output — see *Verifying*.

**A `Session code` line does not prove the agent is alive.** It is read from
`/var/run/aicommander-agent/state.json`, which the agent deletes only from its
SIGINT/SIGTERM handler; an agent killed by `SIGKILL`, by `Illegal instruction`,
or by an unhandled crash leaves the file — with its `PID` and `Started` — in
place for the rest of the boot, and `status --reveal` keeps printing it. `/proc`
is the only liveness answer, which is why the block above ends with the agent
confirmed running. Re-check it at any time with the **Rule 3** liveness function
(`agent_pids`), never with `status`.

Conversely, no `Session code` line means the agent never got as far as
registering: read `$D/agent.log` for the reason, then re-run the step 3 smoke
test against `$D/agent.bin`.

Give the code to the user so they can link the NAS in their dashboard. **It is a
credential** — anyone holding it can run commands as root on the NAS. Do not write
it into any file, and do not repeat it in logs or notes the user did not ask for.

### 5. Survive reboots

`/etc/config/crontab` is durable, but a plain `@reboot` is not reliable on QTS, so
re-check periodically instead. Cron runs this **as root**, which is why step 1
made `$D` root-owned and `0700` and why both this block and the generated script
re-run the Rule 4 path check — the script below is what root executes every 5
minutes, forever:

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
# This block writes into /etc/config/crontab, which is DURABLE. An unresolved $D
# here would append "*/5 * * * * /keepalive.sh" — a permanent entry pointing at a
# path that the next boot destroys, with no error anywhere. Hence the guards above.
[ -x "$D/agent.bin" ] || { echo "No agent at $D/agent.bin — run steps 1-4 first" >&2; exit 1; }

# Rule 4, verify-only, and this is the block it matters most for: the cron entry
# below is the PAYLOAD — a root shell every 5 minutes, forever. Refuse to install
# it while another local user could still put their own keepalive.sh at that path.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}
aic_safe_path "$D"

cat > "$D/keepalive.sh" <<'EOF'
#!/bin/sh
# Runs as root from /etc/config/crontab. Resolves everything itself: nothing here
# may depend on the environment cron does not provide.
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] || exit 1
D="$VOL/aicommander"
[ -x "$D/agent.bin" ] || exit 1

# Rule 4 again, and here it is not a formality: this script is what root runs
# every 5 minutes, so it re-verifies the path on every tick and catches a
# directory swapped in long after the install. Refuse rather than start.
aic_safe_path() {
  p="$1"
  while :; do
    m="$(ls -ld "$p" 2>/dev/null | cut -c1-10)"
    [ -n "$m" ] || { echo "REFUSING: cannot inspect $p — see Rule 4" >&2; exit 1; }
    case "$m" in l*) echo "REFUSING: $p is a symlink; its target cannot be vouched for — install under the real path — see Rule 4" >&2; exit 1 ;; esac
    [ -O "$p" ] || { echo "REFUSING: $p is not owned by root ($m) — whoever owns it can replace $1, which root then executes — see Rule 4" >&2; exit 1; }
    case "$m" in ?????w????|????????w?)
      [ -k "$p" ] || { echo "REFUSING: $p is group/world-writable ($m) and not sticky — any local user can rename it and have root execute their own $1. Fix: chmod +t $p, or install under a share with restricted permissions — see Rule 4" >&2; exit 1; } ;;
    esac
    [ "$p" = "/" ] && break
    p="$(dirname "$p")"
  done
}
# cron discards stderr, so a refusal here would be invisible — the agent would
# just quietly stop coming back. Capture it (the function exits the subshell) and
# put it in the QTS System Event Log, where a user actually looks. Do NOT log it
# into $D/agent.log: $D is the thing under suspicion.
aic_err="$( (aic_safe_path "$D") 2>&1 )" || {
  [ -x /sbin/write_log ] && /sbin/write_log "AICommander keepalive did not start the agent: $aic_err" 2
  exit 1
}

# Already running? (Same /proc discovery as Rule 3 — BusyBox `ps` cannot see the
# full path.) Both the supervisor and its worker match; either one means "up".
for p in /proc/[0-9]*; do
  e="$(readlink "$p/exe" 2>/dev/null)" || continue
  case "$e" in "$D/agent.bin"|"$D/agent.bin (deleted)") ;; *) continue ;; esac
  tr '\0' '\n' < "$p/cmdline" 2>/dev/null | grep -qx run && exit 0
done

export AICOMMANDER_CONFIG_DIR="$D/config"
# Same reason as step 4 (Rule 2): without it a session.json this restart could not
# write is swallowed, and the agent comes up on a code it never persisted. A cron
# restart is exactly when that happens — a volume that did not mount yet.
export AICOMMANDER_SERVICE=1
umask 077
: >> "$D/agent.log" && chmod 600 "$D/agent.log"
setsid "$D/agent.bin" run < /dev/null > /dev/null 2>> "$D/agent.log" &
EOF
chown 0:0 "$D/keepalive.sh" && chmod 700 "$D/keepalive.sh" || exit 1

LINE="*/5 * * * * $D/keepalive.sh"
# Whole-line fixed-string match (-x), not a substring: a substring test would see
# a WRONG entry left by an earlier run (e.g. "*/5 * * * * /keepalive.sh") as "the
# entry is already there" and refuse to add the correct one — the corrective run
# would then report success and change nothing.
if ! grep -qxF "$LINE" /etc/config/crontab 2>/dev/null; then
  # If the file does not end in a newline, `>>` would glue our entry onto the last
  # line and corrupt an unrelated cron job. `$(tail -c 1 …)` is empty exactly when
  # that last byte IS a newline.
  if [ -s /etc/config/crontab ] && [ -n "$(tail -c 1 /etc/config/crontab)" ]; then
    echo "" >> /etc/config/crontab || exit 1
  fi
  echo "$LINE" >> /etc/config/crontab || exit 1
fi

# Any OTHER keepalive.sh entry is a leftover from a run that could not resolve $D.
# It is inert (the path does not exist) but it is durable, so say so — a human has
# to remove it; editing crontab lines out is not something to automate here.
grep -F 'keepalive.sh' /etc/config/crontab | grep -qvxF "$LINE" && \
  echo "WARNING: /etc/config/crontab holds another keepalive.sh entry — remove it by hand, then re-run 'crontab /etc/config/crontab'" >&2

crontab /etc/config/crontab || exit 1
/etc/init.d/crond.sh restart

# Confirm what actually landed in the durable file.
grep -n 'keepalive.sh' /etc/config/crontab
```

Being in the `administrators` group is **not** enough for this step:
`/etc/config/crontab` is not group-writable and `sudo` wants a password. You are
root (Rule 0), so it works — but re-check `id -u` if any of the writes above fails.

## Verifying — check where the identity landed, not just the code

The thing Rule 2 has to guarantee is that the identity and the session code are on
**durable storage**. That is a question about files on disk, so check the files.
This needs nothing stopped and no reboot:

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"

# device.json is written at startup, and only ever under AICOMMANDER_CONFIG_DIR
# when the variable is set — so its absence here means the running agent never
# saw the export and is storing its identity on the ramdisk.
[ -s "$D/config/device.json" ] \
  || { echo "NOT DURABLE: no $D/config/device.json — the agent was started without AICOMMANDER_CONFIG_DIR (Rule 2) and the pairing dies at the next reboot. Stop it (Rule 3) and redo step 4." >&2; exit 1; }

# session.json is written once the relay has answered. Missing it with device.json
# present means registration has not completed — not a durability problem.
[ -s "$D/config/session.json" ] \
  || { echo "NO SESSION STORED: $D/config/device.json exists but session.json does not — the agent has not completed a registration. Read $D/agent.log." >&2; exit 1; }

ls -l "$D/config"                                      # both 0600, in a 0700 dir

# An inherited copy in the default locations is LEGITIMATE on a NAS that was
# paired before the override was turned on — the agent copies it forward, it does
# not move it. Report it; do not treat it as a failure.
for f in /etc/aicommander-agent/device.json /etc/aicommander-agent/session.json \
         "$HOME/.config/aicommander-agent/device.json" \
         "$HOME/.config/aicommander-agent/session.json"; do
  if [ -e "$f" ]; then echo "NOTE: inherited copy still present: $f"; fi
done
```

If that loop reports anything, the durable copies above already exist, so the
inherited ones are only read when `$D/config` has none — which is how a *stale*
identity gets adopted later. `/etc` is the ramdisk, so they disappear at the next
reboot anyway; deleting them now (`rm -f`) is safe and removes the ambiguity, and
on a first-ever install there is nothing to delete. Ask before deleting if the
user has other agents on this box.

On the first start after turning the override on, the adoption is visible in
`$D/agent.log` (both lines go to stderr):
`Adopting the existing device identity into AICOMMANDER_CONFIG_DIR (…)` and
`Reusing the session code found in /etc/aicommander-agent.`

### The restart test — useful, but not proof on its own

A restart-in-place shows the agent comes back on the same code without disrupting
a production NAS. Note the code, stop it with the **Rule 3** stop command (wait
for `agent stopped`), then run the **step 4** start block unchanged — it exports
`AICOMMANDER_CONFIG_DIR` and `AICOMMANDER_SERVICE=1` (Rule 2), re-checks the path
(Rule 4), refuses to start a second agent, and waits for registration instead of
guessing. Do not hand-roll a shorter restart: a `setsid …` line without those two
exports is how an agent ends up on a code it never persisted:

```sh
VOL="$(/sbin/getcfg SHARE_DEF defVolMP -f /etc/config/def_share.info)"
[ -n "$VOL" ] && [ -d "$VOL" ] || { echo "No data volume: '$VOL' — see Rule 1" >&2; exit 1; }
D="$VOL/aicommander"; : "${D:?install directory unresolved — see Rule 1}"
"$D/agent.bin" status --reveal                         # note the code, then stop + start
```

A **different** code afterwards is a real failure — go back to Rule 2. But the
**same** code proves less than it looks: with the override set, the agent also
reads back `/etc/aicommander-agent` and `~/.config/aicommander-agent`
(`session-store.ts`, `device.ts`), and within a single boot the ramdisk still
holds whatever a start *without* the export wrote there. Such an agent finds the
`/etc` copy, prints `Reusing the session code found in …`, and hands you the same
code — while `$D/config` stays empty and the next reboot loses every linked
account. So run the file check above after the restart too; that is the part that
settles it.

A full reboot is the strongest test (it also exercises the cron entry) but is
optional and disruptive; offer it, do not perform it unprompted.

Read `status` output carefully, because half of it does not apply here:

- **`State` is always `inactive` and `Enabled` always `no`.** Both are read from
  `systemctl`, which does not exist on QTS, so they say nothing about the agent —
  not even whether it is running. Ignore them; never report them to the user as a
  fault.
- **`Session code`, `PID`, `Started` and `Server` are real, but they are not a
  liveness signal.** They come from `/var/run/aicommander-agent/state.json`, which
  the agent rewrites on every start — so they exist only for the current boot and
  only for root. The file is removed only by the agent's SIGINT/SIGTERM handler:
  after a `SIGKILL`, an `Illegal instruction`, or a crash it stays behind, and
  `status --reveal` keeps reporting the dead process's code, PID and start time
  until the next reboot. Confirm liveness with the Rule 3 `/proc` check instead.
  `AICOMMANDER_CONFIG_DIR` is not involved in any of these fields.

## What a QNAP install cannot do — tell the user up front

- **Secure exec (service tokens) always fails.** The agent resolves the
  `aicommander-exec` sandbox account at call time, and nothing here creates it —
  it comes only from `aicommander-agent install`, which needs the `useradd` QTS
  does not have (Rule 3). Every `/api/v1/secure-exec` request therefore answers
  `secure exec user "aicommander-exec" not found in /etc/passwd — reinstall the
  agent to create it`. Ordinary remote commands are unaffected; say this before
  the user mints a service token, not after.
- **Screenshots are unsupported** — screen capture covers macOS and Windows only.
- **`status` cannot tell you whether the agent is alive** — `State` / `Enabled`
  come from a `systemctl` that does not exist, and a `Session code` line can be
  left over from a crashed process. Use the Rule 3 `/proc` check; see *Verifying*.

## Troubleshooting

| Symptom | Cause and fix |
|---|---|
| `Illegal instruction` (exit 132), or `--version` prints nothing | The binary needs AVX2 and this CPU lacks it — common on Celeron/Atom NAS boxes. Confirm with `grep -c avx2 /proc/cpuinfo` (`0` = affected). Releases from 1.0.37 ship a baseline x86-64 build that runs without AVX2, so **retry with the current release**; if the newest published version still crashes, this NAS needs a baseline build that is not yet released — report it rather than working around it. |
| Step 3 says the agent is older than 1.0.37 | The published release predates `AICOMMANDER_CONFIG_DIR` and the baseline build. There is no workaround: installing it anyway loses the pairing on every reboot. Report it and stop. |
| `curl: (23) Failure writing output` | Staging in `/tmp` (64 MB) or `/` (~70 MB free). Stage on the data volume, as step 1 does. |
| `npm: command not found` | Expected — there is no npm on QTS. Use this skill's install procedure. |
| `status --reveal` prints no session code | The agent never registered — it died at startup, or the relay is unreachable (the log is stderr-only, so an empty log is normal for a healthy agent — its size proves nothing). Read `$D/agent.log` for the reason, then re-run step 3 against the binary. |
| `status --reveal` prints a session code but nothing works | `state.json` outlives a crashed agent for the rest of the boot, so this output can describe a dead process. Check `/proc` with the Rule 3 `agent_pids` function; if it prints no PID, start it again with step 4. |
| The relay says the machine is offline while `status` looks healthy | Same cause as above — trust `agent_pids`, not `status`. |
| New session code after every reboot | `AICOMMANDER_CONFIG_DIR` unset, pointing at a non-durable path, or an agent older than 1.0.37 that ignores it (step 3). Confirm with the file check in *Verifying*: no `$D/config/device.json` means the store never moved off the ramdisk. If the directory *is* right, the agent was probably started without `AICOMMANDER_SERVICE=1` (Rule 2) and a failed write went unreported — restart it with the full step 4 block and read `$D/agent.log`. |
| `REFUSING: … not sticky` / `not owned by root` | Rule 4: some directory on the way to `$D` can be renamed by another local user, so root's cron entry could be pointed at their script. Apply the `chmod +t` the message names (root-owned parents only) or install under a share with restricted permissions, then re-run the block. Do not edit the check out. |
| `Failed to persist session credentials …` in `$D/agent.log` | `AICOMMANDER_SERVICE=1` is doing its job: the session store is not writable (wrong owner on `$D/config`, read-only or full volume). Fix the storage — the agent is refusing to run on a code it cannot keep. |
| `Installed openssl cannot verify Ed25519 signatures` | This firmware's `openssl` is LibreSSL or predates `pkeyutl -rawin` (OpenSSL 3.0). It is **not** a bad download — that would say `SIGNATURE INVALID`. Install OpenSSL 3.x; do not skip the check, the binary runs as root. |
| The cron entry reads `*/5 * * * * /keepalive.sh` | A step-5 block was run without its preamble, so `$D` was empty (Rule 1). `/etc/config/crontab` is durable, so it stays until removed: delete the line by hand, then re-run the whole step 5 block and check `grep -n keepalive.sh /etc/config/crontab`. |
| Two session codes / the agent came back after an upgrade | The binary was replaced or a start was issued while the old process was still alive. Run the Rule 3 stop until it prints `agent stopped`, then step 4. |
| CLI subcommand reports a brand-new identity | You ran it without `AICOMMANDER_CONFIG_DIR`; export the same value the service uses. |
| Agent gone after reboot | Installed to `/usr/local/bin` (ramdisk), or no cron entry to restart it. |
| `pkill` "worked" but the agent is still running | BusyBox has no `pkill`; it exited 127. Use the `/proc` stop command in Rule 3. |
| Agent restarts by itself after you kill it | You killed only the worker; the supervisor respawned it. Kill every PID the Rule 3 command finds. |
| Commands needing root fail | The agent must run as root (Rule 0), and Linux has no `elevated` mode. |
| `mkdir` or `chown` fails on the data volume | You are not root, or the volume is read-only. Re-check `id -u` and `[ -w "$VOL" ]`. |

## Do not

- Do not call `aicommander-agent install` — it wants systemd and `useradd`.
- Do not install into `/usr/local`, `/opt`, `/etc` or `/var` — all volatile.
- Do not skip the checksum, signature or version checks, and do not download the
  signing key — it is pinned in step 2.
- Do not run the agent, the CLI or the cron entry as anyone but root (Rule 0).
- Do not start the agent — anywhere, including a quick manual restart or the cron
  script — without `AICOMMANDER_SERVICE=1` beside `AICOMMANDER_CONFIG_DIR`
  (Rule 2). Without it a failed session write is silently ignored.
- Do not soften the Rule 4 path check into a warning, and do not install into a
  directory whose parents another local user can rename — that hands them root
  via the cron entry. Fix the path (`chmod +t`) or pick a restricted share.
- Do not leave the install directory group/world-writable, and do not redirect the
  agent's stdout into a file — that is where the session code appears.
- Do not print the full session code anywhere the user did not ask for it.
- Do not run a block without the Rule 1 preamble, and do not split one into pieces
  that assume `$D` is still set — every block is a whole (Rule 1).
- Do not treat `status` output as proof the agent is alive, and do not `mv` the
  binary or start an agent before the Rule 3 stop reports `agent stopped` (or
  `agent not running`).
- Do not reboot the NAS to verify; restart the process instead.
