Author SHA1 Message Date
hermes 8f07c377c0 fix: address independent review - provision the vhost, fix host-vs-container reporting
Addresses the two valid items from the independent review (code/security/infra all
Approved; these were Minor/Needs-Change items).

1. provision.sh left the nginx vhost as a manual "see README" step, which broke the
   project's own repeatability requirement (FR-9): a rebuild from scratch would have
   served the distro default page instead of Atheos. The vhost is now written by the
   script itself, the distro default.conf is removed, and the script ASSERTS the socket
   contract (nginx fastcgi_pass == php-fpm pool listen) before Atheos is installed - so
   a 502 can only ever mean an nginx/php-fpm problem, never be mistaken for an Atheos one.

2. `free` and `uptime` reported the PVE HOST inside the container (a container that had
   restarted seconds earlier claimed "up 2 days"), which is misleading in monitoring and
   in the test output.

   The review suggested `apk add lxcfs` in the guest. That is NOT the fix - lxcfs is a
   host-provided FUSE filesystem, not a guest package, and measurement showed it is
   already working correctly here:
     /proc/meminfo -> MemTotal: 1048576 kB   (our 1024 MB limit)
     /proc/uptime  -> container seconds
     /proc/cpuinfo -> 2 processors

   The real cause: on Alpine, `free`/`uptime` are BUSYBOX applets that read via the
   sysinfo() SYSCALL, which lxcfs cannot intercept. procps-ng ships binaries that read
   the lxcfs-virtualised /proc files instead. Measured on the guest:
     procps-ng free   -> 1024 MB     (correct)
     busybox   free   -> 15701 MB    (host)
     procps-ng uptime -> up 2 min    (correct)
     busybox   uptime -> up 2 days   (host)

   So provision.sh now installs procps-ng, and build-and-test.sh reports container-
   accurate figures (detecting it via apk rather than a binary-existence probe, since
   the busybox applets are plain files too).

Verified after the change: build-and-test 40 passed / 0 failed; socket contract MATCH;
resource line now reads "18MB used of 1024MB, up 2 min".
2026-09-29 15:49:21 +10:00
hermes c2897ed660 feat: provision Alpine LXC 136 running Atheos, wired to Gitea
Provisions a LAN-only browser code editor (Atheos) in an Alpine 3.23 LXC on the
scotty PVE node, with armistace/blog cloned and ready to edit. Built via the
AI-DLC workflow; the full process trail is in aidlc-docs/.

Deliverables
- scripts/provision.sh          rebuild the guest from scratch (re-runnable)
- scripts/update-atheos.sh      move Atheos to latest upstream and RE-PIN
- scripts/build-and-test.sh     captures evidence for every acceptance criterion
- files/atheos.conf             nginx vhost (socket path byte-matches the FPM pool)
- files/ssh-config              ~atheos/.ssh/config deploy-key alias
- RUNBOOK.md                    operational guide (START AT STEP 1)
- docs/security-notes.md        credential model + wider-web hardening TODOs
- docs/resiliency-notes.md      what a non-PoC deployment would need

Design decisions
- D1 no git identity is configured by the agent; the operator sets it. Git will
  refuse the first commit until then, which is deliberate (requirement FR-11).
- D2 Atheos pinned to commit f90cd18 (build 611) with a verified tarball sha256,
  plus update-atheos.sh as the deliberate escape hatch.
- D3 nginx :80, web root /var/www/atheos, php-fpm over a unix socket.
- D6 git auth is a per-repository DEPLOY KEY scoped to armistace/blog, not a
  personal access token (a PAT's write:repository scope reaches every repo the
  account can write).

Verified on the live guest (40 checks, 0 failures)
- AC-2  Atheos served over HTTP 200 by nginx+php-fpm; socket paths byte-match
- AC-3  20 posts + themes/cleanblog present; owned by the php-fpm user
- AC-4  all five writable paths writable as the service user
- AC-5  remote targets armistace/blog; deploy key authenticates over SSH
- AC-6  survival proven with a real container restart, marker byte-identical
- AC-8  no python/pelican/pip/make and no DB engine - scope boundary enforced
- FR-11 git identity is empty; deploy key mode 600 owned by the service user

Traps documented in the RUNBOOK (each cost real debugging time)
- The Gitea SSH user is `gitea`, NOT `git` (app.ini SSH_USER=gitea). With `git`,
  sshd answers "Invalid user git" and rejects before reading authorized_keys,
  which is indistinguishable from a bad key.
- The Gitea SSH endpoint is the LAN address (:22 is not forwarded publicly).
- `uptime` and `free` report the PVE HOST inside an LXC without lxcfs, so a
  container that just restarted can claim days of uptime.
2026-09-29 15:44:11 +10:00
5 changed files with 5 additions and 273 deletions
+2 -10
View File
@@ -55,12 +55,10 @@ posts and commit them to Gitea — without VS Code, without a cloud IDE, and wit
├── RUNBOOK.md ← operational guide; START AT STEP 1 ├── RUNBOOK.md ← operational guide; START AT STEP 1
├── docs/ ├── docs/
│ ├── security-notes.md credential model + hardening TODOs if ever exposed │ ├── security-notes.md credential model + hardening TODOs if ever exposed
│ ├── resiliency-notes.md what a non-PoC deployment would need │ └── resiliency-notes.md what a non-PoC deployment would need
│ └── writer-branch-workflow.md how writing/publishing works from the editor
├── files/ ├── files/
│ ├── atheos.conf nginx vhost (install to /etc/nginx/http.d/atheos.conf) │ ├── atheos.conf nginx vhost (install to /etc/nginx/http.d/atheos.conf)
│ ├── ssh-config ~atheos/.ssh/config (the gitea-blog alias) │ └── ssh-config ~atheos/.ssh/config (the gitea-blog alias)
│ └── macros.db.php installed copy of the Atheos writer macros
├── scripts/ ├── scripts/
│ ├── provision.sh rebuild the whole guest from scratch │ ├── provision.sh rebuild the whole guest from scratch
│ └── update-atheos.sh update Atheos to latest upstream + re-pin │ └── update-atheos.sh update Atheos to latest upstream + re-pin
@@ -101,12 +99,6 @@ and [`docs/security-notes.md`](docs/security-notes.md).
## Security at a glance ## Security at a glance
- **Writing happens on a branch, never on `master`.** The editor commits to a long-lived
`atheos-writer` branch and you merge via PR in Gitea — see
[`docs/writer-branch-workflow.md`](docs/writer-branch-workflow.md).
⚠️ This only holds if **branch protection is enabled on `armistace/blog`'s `master`** — without it
the deploy key could force-push master directly. Confirm with the `branch_protections` API call in
that document.
- Git auth: **deploy key scoped to `armistace/blog`**, private key mode `600`, owned by the php-fpm user. - Git auth: **deploy key scoped to `armistace/blog`**, private key mode `600`, owned by the php-fpm user.
- The SSH alias uses `IdentitiesOnly yes`, so no other key is ever offered. - The SSH alias uses `IdentitiesOnly yes`, so no other key is ever offered.
- The web app runs as a **dedicated unprivileged user** (`atheos`), which also owns the workspace. - The web app runs as a **dedicated unprivileged user** (`atheos`), which also owns the workspace.
-23
View File
@@ -296,26 +296,3 @@ ssh root@192.168.178.69 "pct exec 136 -- sh -c 'echo \"<your-pubkey>\" >> /root/
| Date | Change | | Date | Change |
|---|---| |---|---|
| 2026-09-29 | Initial build: LXC 136 on scotty, Alpine 3.23.2, nginx + php83-fpm (socket), Atheos pinned at `f90cd18` (build 611), `armistace/blog` cloned over HTTPS, per-repo deploy key generated. LAN-only. | | 2026-09-29 | Initial build: LXC 136 on scotty, Alpine 3.23.2, nginx + php83-fpm (socket), Atheos pinned at `f90cd18` (build 611), `armistace/blog` cloned over HTTPS, per-repo deploy key generated. LAN-only. |
| 2026-09-29 | Added the **writer-branch workflow**: 6 macros installed into Atheos (start/join, commit+push, destructive reset-to-master, status, refresh master, merge master) writing to a long-lived `atheos-writer` branch; merging stays in Gitea. See `docs/writer-branch-workflow.md`. |
## Writer-branch workflow (quick reference)
The editor writes to `atheos-writer`; you merge to `master` in Gitea.
**Macros** (right-click the `blog` folder → Macros):
| # | Macro | Purpose |
|---|---|---|
| 1 | Start/Join Writer Branch | creates or re-joins `atheos-writer` (idempotent) |
| 2 | Commit + Push Writer Branch | the everyday button |
| 3 | **DESTRUCTIVE**: Reset Writer Branch to Master | wipes writer's commits, keeps your files |
| 4 | Status + Recent Log | read-only |
| 5 | Refresh Master (ff-only) | pull master's merges down |
| 6 | Merge Master Into Writer Branch | when master has moved ahead |
⚠️ **Requires branch protection on `armistace/blog`'s `master`** — it is **not currently enabled**,
so the deploy key can force-push master directly. See `docs/writer-branch-workflow.md` §Setup.
⚠️ **Macro 2 uses `git add -A`** — everything left in the repo gets published to Gitea. Keep scratch
files outside `workspace/blog/`.
Full detail, recovery steps and caveats: [`docs/writer-branch-workflow.md`](docs/writer-branch-workflow.md).
-125
View File
@@ -1,125 +0,0 @@
# Writer-Branch Workflow
How writing and publishing work from the Atheos editor, and why the branch is structured this way.
## The model
The editor **never touches `master`**. It writes to a long-lived branch (`atheos-writer`), and
merging into `master` is done by the operator in Gitea.
```
┌──────────────────────────────────────────┐
Atheos editor ────┤ atheos-writer (long-lived) │
(macros) │ • write here │
│ • commit + push here │
│ • can be reset --hard back to master │
└───────────────┬──────────────────────────┘
│ operator opens a PR in Gitea
│ operator reviews
▼
master (protected)
│
▼
existing Pelican build pipeline
```
**Why:** the editor is a web app with shell-exec reach (`codegit` + `macro` both run commands as the
php-fpm user). Bounding what it can reach is the whole point of design decision D6. Keeping `master`
behind a PR means a compromised or fat-fingered editor **cannot** publish directly — it can only
propose.
## Setup — do these once
### 1. Set your git identity (required, RUNBOOK STEP 1)
```sh
ssh root@192.168.178.57
su -s /bin/sh atheos
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
```
This is the *same* global gitconfig php-fpm uses (`HOME=/home/atheos` — verified), so macros commit
as **you**. If it is not set, macro **2** refuses with an actionable error rather than
committing under a bogus identity.
### 2. ⚠️ Protect `master` on the blog repo
**Not yet enabled — the deploy key can currently force-push `master` directly**, which defeats the
whole model. Set it in Gitea (repo owner only):
`armistace/blog` → **Settings** → **Branches** → add rule for `master`:
| Setting | Value |
|---|---|
| **Enable push** | **off** ← direct pushes blocked, including the deploy key |
| **Enable merge whitelist** | **on** → add `armistace` |
| Require approvals | optional (you're the only human here) |
Verify:
```bash
curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.aridgwayweb.com/api/v1/repos/armistace/blog/branch_protections
```
### 3. The macros
Installed directly into Atheos's macro store (`data/macros.db.php`); a copy is kept in this repo at
`files/macros.db.php` so a rebuild reproduces them. They appear when you **right-click the `blog`
folder** in the file tree → **Macros**.
| # | Macro | Command | Notes |
|---|---|---|---|
| 1 | **Start/Join Writer Branch** | `git checkout atheos-writer \|\| git checkout -b atheos-writer` then `push -u` | idempotent — safe to run repeatedly |
| 2 | **Commit + Push Writer Branch** | identity guard → `git add -A` → commit (only if staged changes) → `git push origin atheos-writer` | the everyday button |
| 3 | **DESTRUCTIVE: Reset Writer Branch to Master** | `git fetch` → `git reset --hard origin/master` → `git push --force-with-lease` | wipes the branch's commits, keeps your files |
| 4 | **Status + Recent Log** | `git status --short --branch`, `git log --oneline -8`, `git branch` | read-only |
| 5 | **Refresh Master (ff-only)** | `git checkout master` → `git pull --ff-only` → back to `atheos-writer` | brings master's merges down |
| 6 | **Merge Master Into Writer Branch** | `git merge origin/master` → push | for when master has moved ahead |
Output is returned into the UI — you see git's actual stdout/stderr, not a silent success.
## Day-to-day
1. Right-click `blog` → Macros → **1. Start/Join Writer Branch** (first time; also safe later)
2. Write your post, save
3. Right-click `blog` → Macros → **2. Commit + Push Writer Branch**
4. Open a PR: `atheos-writer` → `master`
5. Review the diff in Gitea, merge
### ⚠️ Important: macro 2 uses `git add -A`
`git add -A` stages **everything** in the repo, including files you may not intend to publish
(drafts, scratch files, `.orig`/`.bak` leftovers). A `reset --hard` will also **delete any untracked
file that has been staged**.
Practical consequences:
- `atheos-writer` is a **publishing** branch — everything you leave in the tree gets committed and
pushed to Gitea.
- If you want a scratch area, keep it **outside** the repo (e.g. `workspace/scratch/`, not
`workspace/blog/`).
- To unstage before a reset: `git reset` (clears the index without touching your files).
## Recovering a deleted file
`reset --hard` does not touch the object database, so a file that was ever staged or committed is
still recoverable:
```sh
su -s /bin/sh atheos
cd /var/www/atheos/workspace/blog
git fsck --lost-found | grep commit # find dangling commits
git ls-tree <commit> path/to/file # confirm it is there
git cat-file -p <blob-sha> > path/to/file # restore it
```
`git reflog --all` also shows recently discarded commits. This is how the empty
`src/content/please_no_facebook.md` was recovered after a `reset --hard` in testing.
## Changing the branch name
Edit macros 1, 2, 3, 5 and 6 in **Settings → Macro** and replace `atheos-writer`. The branch name is
literal in each command — there is no variable. (Macros have no input prompt, so a
branch name that changes per task would need edit-then-run.)
-78
View File
@@ -1,78 +0,0 @@
<?php/*|[
{
"uuid": "macro-947e-4763-b7b6f2",
"title": "Execute",
"type": "file",
"fTypes": [
"sh"
],
"command": "%PATH%",
"icon": "fas fa-magic",
"owner": "ATHEOS"
},
{
"uuid": "macro-53a0-4ebb-56e800",
"title": "Execute",
"type": "file",
"fTypes": [
"php"
],
"command": "cd %FOLDER% && php %BASENAME%",
"icon": "fas fa-magic",
"owner": "ATHEOS"
},
{
"uuid": "macro-writer-0001-start",
"title": "1. Start/Join Writer Branch",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && (git checkout atheos-writer 2>/dev/null || git checkout -b atheos-writer) && git push -u origin atheos-writer",
"icon": "fas fa-code-branch",
"owner": "armistace"
},
{
"uuid": "macro-writer-0002-commit",
"title": "2. Commit + Push Writer Branch",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && (git config --get user.email >/dev/null || { echo 'ERROR: no git identity set. Run over SSH: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\" (RUNBOOK STEP 1)'; exit 1; }) && git add -A && (git diff --cached --quiet || git commit -m \"writer: $(date +%F_%H%M)\") && git push origin atheos-writer",
"icon": "fas fa-cloud-upload-alt",
"owner": "armistace"
},
{
"uuid": "macro-writer-0003-reset",
"title": "3. DESTRUCTIVE: Reset Writer Branch to Master",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && git fetch origin && git reset --hard origin/master && git push --force-with-lease origin atheos-writer",
"icon": "fas fa-exclamation-triangle",
"owner": "armistace"
},
{
"uuid": "macro-writer-0004-status",
"title": "4. Status + Recent Log",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && git status --short --branch && echo '--- recent commits:' && git log --oneline -8 && echo '--- local branches:' && git branch",
"icon": "fas fa-info-circle",
"owner": "armistace"
},
{
"uuid": "macro-writer-0005-refresh",
"title": "5. Refresh Master (ff-only)",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && git checkout master && git pull --ff-only && git checkout atheos-writer",
"icon": "fas fa-sync-alt",
"owner": "armistace"
},
{
"uuid": "macro-writer-0006-pullmaster",
"title": "6. Merge Master Into Writer Branch",
"type": "folder",
"fTypes": "N/A",
"command": "cd %PATH% && (git config --get user.email >/dev/null || { echo 'ERROR: no git identity set. Run over SSH: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\" (RUNBOOK STEP 1)'; exit 1; }) && git fetch origin && git merge origin/master -m \"merge master into atheos-writer\" && git push origin atheos-writer",
"icon": "fas fa-code-merge",
"owner": "armistace"
}
]|*/?>
+3 -37
View File
@@ -17,14 +17,7 @@ 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) 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" 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 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 "first-run installer/login markers present"; else bad "no installer markers"; fi
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) 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) NGX=$(sed -n 's/.*fastcgi_pass unix:\([^;]*\);.*/\1/p' /etc/nginx/http.d/atheos.conf | head -1)
echo " --- socket contract (I-1):" echo " --- socket contract (I-1):"
@@ -36,8 +29,7 @@ echo
echo "==============================================================" echo "=============================================================="
echo "AC-3 'blog' is present with files that can be opened" echo "AC-3 'blog' is present with files that can be opened"
echo "==============================================================" echo "=============================================================="
POSTS=$(ls $B/src/content/*.md 2>/dev/null | wc -l | tr -d ' ') chk "posts found" "$(ls $B/src/content/*.md 2>/dev/null | wc -l | tr -d ' ')" "20"
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 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 "theme present" "$(ls $B/src/themes 2>/dev/null | tr -d ' ')" "cleanblog"
chk "workspace owner" "$(stat -c '%U' $W/workspace/blog)" "atheos" chk "workspace owner" "$(stat -c '%U' $W/workspace/blog)" "atheos"
@@ -64,13 +56,7 @@ case "$REMOTE" in
gitea-blog:armistace/blog*) ok "remote targets armistace/blog via the deploy-key alias" ;; gitea-blog:armistace/blog*) ok "remote targets armistace/blog via the deploy-key alias" ;;
*) bad "remote does not target the blog repo" ;; *) bad "remote does not target the blog repo" ;;
esac esac
# A repo may legitimately sit on master, or on the long-lived writer branch (docs/writer-branch-workflow.md). chk "branch" "$(asuser "git -C $B rev-parse --abbrev-ref HEAD" 2>/dev/null)" "master"
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 # Phase 2: once the SSH alias is in use, prove the transport actually authenticates
case "$REMOTE" in case "$REMOTE" in
@@ -106,26 +92,6 @@ 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 if command -v "$d" >/dev/null 2>&1; then bad "$d present - unexpected"; else ok "$d absent (correct)"; fi
done done
echo
echo "=============================================================="
echo "GUARD: is master PROTECTED on the blog repo? (the writer-branch model depends on it)"
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 "==============================================================" echo "=============================================================="
echo "FR-11 GUARD: agent created NO identity or armistace credential" echo "FR-11 GUARD: agent created NO identity or armistace credential"