Reported as "deleting terminal just cycles in the ui". Root cause is much larger than the
plugin: it was a platform bug that broke deletion everywhere.
CAUSE
traits/helpers.php::rDelete() did:
glob($target . "{*,.[!.]*,..?*}", GLOB_BRACE|GLOB_MARK);
GLOB_BRACE is a GLIBC extension. Alpine is musl, where the constant is undefined,
and referencing an undefined constant is a FATAL in PHP 8:
PHP Fatal error: Uncaught Error: Undefined constant "GLOB_BRACE"
in traits/helpers.php:138
#0 components/market/class.market.php(178): Common::rDelete()
#1 components/market/controller.php(46): Market->remove()
The fatal kills the request before Common::send(200), so the front end never gets a
settle response and the UI spins forever. A hang, not an error - which is why it
presented as "cycles in the ui".
BLAST RADIUS (measured, two call sites)
components/market/class.market.php:178 removing a plugin from the market
components/filetree/class.filetree.php:59 DELETING ANY FILE OR FOLDER IN THE EDITOR
The second is core functionality, so this was never a plugin bug - installing the
Terminal plugin is just what made it visible.
FIX
patches/helpers-rDelete-GLOB_BRACE.diff - expand the brace pattern by hand when
GLOB_BRACE is undefined, keeping upstream behaviour where it is defined. Verified:
nested dirs + dotfiles + single files all delete correctly now.
Upstream has NOT fixed this (checked main) so it cannot be resolved by updating.
PLUGIN REMOVED
Once deletion worked, removal via the market succeeded and the plugin is now gone:
{"text":"Plugin successfully removed.","status":200}
That is the right end state - a browser shell was the largest attack surface, and
macros already provide command execution. Removed through the market's own code path
so the plugin list, addons cache and bundle stayed consistent.
TESTS (now 42 passed, 1 failed - the failure is branch protection, a genuine open item)
+ a GLOB_BRACE regression guard that fails loudly if the patch is lost to an update
+ the identity guard is INVERTED: FR-11 required an EMPTY identity so the agent could
not impersonate the operator, but the operator has since asked for it to be set, so
the test asserting emptiness was asserting the opposite of the current design. It
now requires the identity to be present and to NOT look agent-generated.
docs/known-issues.md: new section 1 documents the GLOB_BRACE fault in full, including the
honest note that this is the real cost of choosing Alpine over a glibc base image.
197 lines
11 KiB
Bash
197 lines
11 KiB
Bash
#!/bin/sh
|
|
# ==============================================================================
|
|
# build-and-test.sh — captures evidence for every acceptance criterion (AC-1..AC-8)
|
|
# Run ON THE GUEST: ssh root@192.168.178.57 'sh -s' < build-and-test.sh
|
|
# ==============================================================================
|
|
W=/var/www/atheos
|
|
B=$W/workspace/blog
|
|
pass=0; fail=0
|
|
ok() { echo " PASS $1"; pass=$((pass+1)); }
|
|
bad() { echo " FAIL $1"; fail=$((fail+1)); }
|
|
chk() { if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1 (got '$2', want '$3')"; fi; }
|
|
asuser() { su -s /bin/sh atheos -c "$1"; }
|
|
|
|
echo "=============================================================="
|
|
echo "AC-2 Atheos is served by nginx+PHP (not 502/404)"
|
|
echo "=============================================================="
|
|
CODE=$(wget -qO /tmp/idx.html --server-response http://127.0.0.1/ 2>&1 | sed -n 's/.*HTTP\/1\.[01] \([0-9]*\).*/\1/p' | head -1)
|
|
chk "HTTP status for /" "$CODE" "200"
|
|
if grep -qi '<title>Atheos IDE</title>' /tmp/idx.html 2>/dev/null; then ok "served page is the Atheos IDE"; else bad "served page is not Atheos"; fi
|
|
# Either state is valid: pre-install shows the installer, post-install shows the login/app.
|
|
if grep -qiE 'install|setup' /tmp/idx.html 2>/dev/null; then
|
|
ok "installer markers present (pre-install state)"
|
|
elif [ -f "$W/config.php" ] && [ -s "$W/config.php" ]; then
|
|
ok "installer already completed (config.php populated)"
|
|
else
|
|
bad "neither installer markers nor a populated config.php"
|
|
fi
|
|
SOCK=$(sed -n 's/^listen = //p' /etc/php83/php-fpm.d/www.conf | head -1)
|
|
NGX=$(sed -n 's/.*fastcgi_pass unix:\([^;]*\);.*/\1/p' /etc/nginx/http.d/atheos.conf | head -1)
|
|
echo " --- socket contract (I-1):"
|
|
echo " php-fpm listen : $SOCK"
|
|
echo " nginx fastcgi : $NGX"
|
|
chk "socket paths byte-match" "$NGX" "$SOCK"
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "AC-3 'blog' is present with files that can be opened"
|
|
echo "=============================================================="
|
|
POSTS=$(ls $B/src/content/*.md 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "$POSTS" -ge 20 ]; then ok "posts found ($POSTS, expected >= 20)"; else bad "posts found ($POSTS, expected >= 20)"; fi
|
|
if [ -d "$W/workspace/blog/.git" ]; then ok "it is a git working copy"; else bad "not a git working copy"; fi
|
|
chk "theme present" "$(ls $B/src/themes 2>/dev/null | tr -d ' ')" "cleanblog"
|
|
chk "workspace owner" "$(stat -c '%U' $W/workspace/blog)" "atheos"
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "AC-4 An edit persists to disk (as the FPM user)"
|
|
echo "=============================================================="
|
|
asuser "printf 'x\n' > $B/.ac4-probe" >/dev/null 2>&1
|
|
if [ "$(asuser "cat $B/.ac4-probe" 2>/dev/null)" = "x" ]; then ok "write+read back as 'atheos'"; else bad "cannot write as atheos"; fi
|
|
asuser "rm -f $B/.ac4-probe" >/dev/null 2>&1
|
|
for p in config.php data workspace plugins themes; do
|
|
if asuser "test -w $W/$p" 2>/dev/null; then ok "writable: $p"; else bad "NOT writable: $p"; fi
|
|
done
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "AC-5 Remote points at Gitea; the SSH switch is documented"
|
|
echo "=============================================================="
|
|
REMOTE=$(asuser "git -C $B remote get-url origin" 2>/dev/null)
|
|
echo " remote now: $REMOTE"
|
|
case "$REMOTE" in
|
|
*aridgwayweb.com*armistace/blog*) ok "remote targets armistace/blog on Gitea (HTTPS)" ;;
|
|
gitea-blog:armistace/blog*) ok "remote targets armistace/blog via the deploy-key alias" ;;
|
|
*) bad "remote does not target the blog repo" ;;
|
|
esac
|
|
# A repo may legitimately sit on master, or on the long-lived writer branch (docs/writer-branch-workflow.md).
|
|
BR=$(asuser "git -C $B rev-parse --abbrev-ref HEAD" 2>/dev/null)
|
|
case "$BR" in
|
|
master|main) ok "branch ($BR)" ;;
|
|
atheos-writer) ok "branch ($BR - writer-branch workflow)" ;;
|
|
*) bad "branch is unexpected: $BR" ;;
|
|
esac
|
|
|
|
# Phase 2: once the SSH alias is in use, prove the transport actually authenticates
|
|
case "$REMOTE" in
|
|
gitea-blog:*)
|
|
AUTH=$(asuser 'ssh -o BatchMode=yes -o ConnectTimeout=8 -T gitea-blog 2>&1' | head -1)
|
|
case "$AUTH" in
|
|
*successfully\ authenticated*) ok "deploy key authenticates over SSH" ;;
|
|
*) bad "deploy key does NOT authenticate: $AUTH" ;;
|
|
esac
|
|
;;
|
|
esac
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "AC-6 Survives reboot (services enabled, data intact)"
|
|
echo "=============================================================="
|
|
echo " container uptime: $(cut -d. -f1 /proc/uptime)s (reboot was issued during the test)"
|
|
for s in nginx php-fpm83 sshd; do
|
|
if rc-update show 2>/dev/null | grep -qE "^\s+$s\s+\|\s+default"; then ok "$s enabled at boot"; else bad "$s NOT enabled at boot"; fi
|
|
if rc-service "$s" status >/dev/null 2>&1; then ok "$s running"; else bad "$s not running"; fi
|
|
done
|
|
if [ -S "$SOCK" ]; then ok "fpm socket exists after reboot"; else bad "fpm socket missing"; fi
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "AC-8 SCOPE GUARD: no build toolchain present (must be ABSENT)"
|
|
echo "=============================================================="
|
|
for t in python python3 pelican pip pip3 make; do
|
|
if command -v "$t" >/dev/null 2>&1; then bad "$t IS present - scope violated!"; else ok "$t absent (correct)"; fi
|
|
done
|
|
echo " --- also check no database engine was installed (Atheos needs none):"
|
|
for d in mysqld mariadbd postgres redis-server; do
|
|
if command -v "$d" >/dev/null 2>&1; then bad "$d present - unexpected"; else ok "$d absent (correct)"; fi
|
|
done
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "REGRESSION: rDelete works (GLOB_BRACE is glibc-only; musl lacks it)"
|
|
echo "=============================================================="
|
|
# traits/helpers.php::rDelete() used GLOB_BRACE, which is undefined under musl -> PHP 8 fatal.
|
|
# That broke deleting ANY file/folder in the editor AND removing a market plugin (the UI
|
|
# would spin forever because the fatal killed the response before Common::send()).
|
|
# docs/known-issues.md §1. This test fails loudly if the patch is lost (e.g. by an update).
|
|
rm -rf /tmp/rdel_guard 2>/dev/null
|
|
if mkdir -p /tmp/rdel_guard/a/b/c /tmp/rdel_guard/.hd 2>/dev/null \
|
|
&& touch /tmp/rdel_guard/f.txt /tmp/rdel_guard/.dot /tmp/rdel_guard/a/b/c/deep.txt; then
|
|
ROUT=$(php83 -r 'require_once("/var/www/atheos/common.php"); echo Common::rDelete("/tmp/rdel_guard/") ? "ok" : "fail";' 2>&1 | grep -viE 'deprecat|warning|notice' | tail -1)
|
|
if [ "$ROUT" = "ok" ] && [ ! -e /tmp/rdel_guard ]; then
|
|
ok "rDelete removed a nested tree incl. dotfiles (delete works)"
|
|
else
|
|
if echo "$ROUT" | grep -q 'GLOB_BRACE'; then
|
|
bad "rDelete FATALS on GLOB_BRACE - re-apply patches/helpers-rDelete-GLOB_BRACE.diff"
|
|
else
|
|
bad "rDelete failed (got: $ROUT)"
|
|
fi
|
|
fi
|
|
else
|
|
bad "could not build the rDelete test tree"
|
|
fi
|
|
rm -rf /tmp/rdel_guard 2>/dev/null
|
|
echo "=============================================================="
|
|
# The whole branch-based workflow assumes the deploy key CANNOT push master. That is a
|
|
# repo-side setting we cannot verify from here by inspection - so test it the way an
|
|
# attacker would: attempt a direct push to master and require it to be REJECTED.
|
|
if [ "${SKIP_MASTER_PUSH_TEST:-0}" = "1" ]; then
|
|
echo " SKIPPED (SKIP_MASTER_PUSH_TEST=1)"
|
|
else
|
|
PUSH_OUT=$(asuser "git -C $B push origin HEAD:master --dry-run" 2>&1)
|
|
if echo "$PUSH_OUT" | grep -qiE 'protected|not allowed|denied|forbidden|remote rejected'; then
|
|
ok "master is protected - the deploy key CANNOT push it"
|
|
else
|
|
bad "master is NOT protected - the deploy key could push master directly!"
|
|
echo " Enable it: armistace/blog -> Settings -> Branches -> add rule for 'master',"
|
|
echo " Enable push = OFF, merge whitelist = armistace. See docs/writer-branch-workflow.md"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "IDENTITY: git identity is SET by the operator (FR-11 superseded by explicit request)"
|
|
echo "=============================================================="
|
|
# FR-11 originally required the agent to leave the identity EMPTY so it could not impersonate
|
|
# the operator. The operator has since explicitly asked for it to be set, so the requirement is
|
|
# superseded - and the guard is inverted: the identity must now be present and must be the
|
|
# OPERATOR's, not an agent-invented one. Guarding the old expectation would assert the opposite
|
|
# of the current design and fail forever (which is what it did).
|
|
NAME=$(asuser 'git config --get user.name' 2>/dev/null)
|
|
MAIL=$(asuser 'git config --get user.email' 2>/dev/null)
|
|
if [ -n "$NAME" ]; then ok "git user.name set ($NAME)"; else bad "git user.name is empty - macro 2 will refuse to commit"; fi
|
|
if [ -n "$MAIL" ]; then ok "git user.email set ($MAIL)"; else bad "git user.email is empty"; fi
|
|
case "$MAIL" in
|
|
*hermes*|*bot*|*noreply@anthropic*|*agent*) bad "identity looks agent-generated: $MAIL" ;;
|
|
"") ;;
|
|
*) ok "identity is an operator address, not an agent-invented one" ;;
|
|
esac
|
|
if [ -f /home/atheos/.ssh/id_ed25519_blog ]; then ok "deploy key present"; else bad "deploy key missing"; fi
|
|
chk "deploy key mode" "$(stat -c '%a' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "600"
|
|
chk "deploy key owner" "$(stat -c '%U' /home/atheos/.ssh/id_ed25519_blog 2>/dev/null)" "atheos"
|
|
chk "ssh config mode" "$(stat -c '%a' /home/atheos/.ssh/config 2>/dev/null)" "600"
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
echo "RESOURCES"
|
|
echo "=============================================================="
|
|
echo " rootfs: $(df -hP / | tail -1 | awk '{print $2" total, "$3" used, "$4" avail ("$5")"}')"
|
|
# NOTE: on Alpine, `free`/`uptime` are BUSYBOX applets that use the sysinfo() syscall, which
|
|
# lxcfs cannot intercept - so they report the PVE HOST, not this container. procps-ng (installed
|
|
# by provision.sh) replaces them with binaries that read the lxcfs-virtualised /proc files.
|
|
# Detect via apk, which is the reliable signal (the binaries are plain files either way).
|
|
if apk info -e procps-ng >/dev/null 2>&1; then
|
|
echo " memory: $(/usr/bin/free -m 2>/dev/null | awk 'NR==2{print $3"MB used of "$2"MB"}')"
|
|
echo " uptime: $(/usr/bin/uptime 2>/dev/null | sed 's/.*up /up /;s/,.*//') (container-accurate)"
|
|
else
|
|
echo " memory: (procps-ng NOT installed - busybox free/uptime would report the HOST)"
|
|
fi
|
|
echo " lxcfs virtualises /proc: meminfo=$(grep MemTotal /proc/meminfo | awk '{print $2"kB"}') uptime=$(cut -d. -f1 /proc/uptime)s"
|
|
echo " listening ports: $(netstat -tln 2>/dev/null | awk '/LISTEN/{split($4,a,":"); print a[length(a)]}' | sort -u | tr '\n' ' ')"
|
|
|
|
echo
|
|
echo "=============================================================="
|
|
printf 'RESULT: %d passed, %d failed\n' "$pass" "$fail"
|
|
echo "=============================================================="
|
|
[ "$fail" -eq 0 ] || exit 1
|