WEBURLFILE="$REPOROOT/web-tunnel-url.txt"
Root cause: Cloudflare quick tunnels (trycloudflare.com) are ephemeral — the URL rotates/expires while eduos-tunnel keeps running, so the container looks healthy but the documented URL silently 404s. The old README carried two rows (primary + backup), but there is only ever one tunnel, so the backup row is dead weight and must be dropped.
Fix: a rotation script rotate-tunnel-url.sh that (1) extracts the live URL from docker logs eduos-tunnel, (2) refuses to touch anything unless the URL answers HTTP 200, (3) rotates it into README.md + web-tunnel-url.txt + .coding-hermes/{api,web}-tunnel-url.txt, dropping the backup row. The HTTP 200 check runs before any write, satisfying the "verify curl 200 before commit" requirement.
#!/usr/bin/env bash
# rotate-tunnel-url.sh — rotate the EduOS Cloudflare quick-tunnel URL.
# Exit codes: 0 = rotated & verified | 1 = no URL found in logs
# 2 = URL found but failed the HTTP 200 gate (nothing changed)
# Usage: rotate-tunnel-url.sh [REPO_ROOT] (default: current directory)
# Knobs: DOCKER_LOGS_CMD (default: docker logs eduos-tunnel 2>&1),
# TUNNEL_CHECK_URL (default: the extracted URL)
set -euo pipefail
REPO_ROOT="${1:-$(pwd)}"
README="$REPO_ROOT/README.md"
WEB_URL_FILE="$REPO_ROOT/web-tunnel-url.txt"
HERMES_DIR="$REPO_ROOT/.coding-hermes"
API_URL_FILE="$HERMES_DIR/api-tunnel-url.txt"
HERMES_WEB_URL_FILE="$HERMES_DIR/web-tunnel-url.txt"
log() { printf '[rotate-tunnel] %s\n' "$*" >&2; }
# --- 1. Extract the live URL from the container logs (last one wins) ----
DOCKER_LOGS_CMD="${DOCKER_LOGS_CMD:-docker logs eduos-tunnel 2>&1}"
NEW_URL="$(eval "$DOCKER_LOGS_CMD" \
| grep -oE 'https?://[a-z0-9-]+\.trycloudflare\.com' \
| tail -n 1 || true)"
if [[ -z "$NEW_URL" ]]; then
log "ERROR: no trycloudflare.com URL found in eduos-tunnel logs"
log " (cmd: $DOCKER_LOGS_CMD) — is the tunnel container up?"
exit 1
fi
log "live tunnel URL: $NEW_URL"
# --- 2. Gate: only rotate a URL that actually answers HTTP 200 ----------
CHECK_URL="${TUNNEL_CHECK_URL:-$NEW_URL}"
HTTP_CODE="$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 20 "$CHECK_URL" || true)"
if [[ "$HTTP_CODE" != "200" ]]; then
log "ERROR: $CHECK_URL returned HTTP $HTTP_CODE (expected 200); NOT rotating"
log " (tunnel may still be starting or already dead — re-run later)"
exit 2
fi
log "verified HTTP 200: $CHECK_URL"
# --- 3. Rotate into all state files -------------------------------------
echo "$NEW_URL" > "$WEB_URL_FILE"
mkdir -p "$HERMES_DIR"
echo "$NEW_URL" > "$API_URL_FILE"
echo "$NEW_URL" > "$HERMES_WEB_URL_FILE"
# --- 4. README: update the primary tunnel row, drop the backup row -------
if [[ -f "$README" ]]; then
perl -ni -e '
if (index($_, "trycloudflare.com") >= 0) {
if (index($_, "|") >= 0) { # table row
if ($done) { next; } # backup row: single tunnel, drop it
s{https?://[a-z0-9-]+\.trycloudflare\.com/?}{'"$NEW_URL"'}ig;
$done = 1; # primary row: refresh URL (text + href)
} else { # prose line: refresh URL in place
s{https?://[a-z0-9-]+\.trycloudflare\.com/?}{'"$NEW_URL"'}ig;
}
}
print;
' "$README"
log "README updated (primary row rotated, backup row removed)"
else
log "WARNING: $README not found; skipped README update"
fi
log "done — tunnel URL rotated and verified (HTTP 200)."
Commit (only after the gate passes — the script already guarantees that):
./rotate-tunnel-url.sh . && \
git add README.md web-tunnel-url.txt .coding-hermes/api-tunnel-url.txt .coding-hermes/web-tunnel-url.txt && \
git commit -m "docs: rotate trycloudflare tunnel URL (single tunnel, drop backup row)"
Why tail -1: logs accumulate every URL the tunnel has ever bound, oldest first — the last trycloudflare.com match is the current one. Why -L on curl: quick tunnels redirect / and http→https. Why perl -ni not -pi: in -p mode perl's implicit continue { print } runs even after next, so a dropped row would still print; -n + explicit print makes the drop real. The /g flag handles markdown rows where the URL appears twice ([text](href)).
This sandbox has no docker daemon and no live EduOS repo, so I validated every path of the script against representative fixtures: mock container logs with docker-style timestamps + ANSI color codes, a mock README with primary + backup rows plus a prose URL mention, and a local HTTP server for the 200/404/000 cases. `DOCKER_LOGS_CMD='cat <logfile>'` exercises the exact extraction pipeline the production `docker logs` invocation uses. | # | Test | Result | |---|------|--------| | T1 | Extraction: log with 3 old URLs (timestamps + `\e[36m` color) → last occurrence wins | `https://live-tunnel-abc123.trycloudflare.com` ✓ | | T1b | Extraction on dead/no-URL fixtures | dead → URL found; empty log → empty ✓ | | T2 | No URL in logs → exit 1, all 4 files byte-identical (sha256) | ✓ | | T3 | Live server returns 000 (dead host) → gate refuses, exit 2, files untouched | ✓ | | T4 | Full success e2e: URL extracted → 200 gate passed → all 3 txt files rewritten, README primary row (display **and** href) updated, **backup row deleted**, prose mention updated | ✓ | | T5 | Re-run same URL → idempotent, zero diff | ✓ | | T6 | README missing → warning, state files still rotated | ✓ | | T7 | Production default `docker logs eduos-tunnel` (no daemon here) → clean exit 1, files untouched | ✓ | | T8 | Live server answers 404 → gate refuses, exit 2, files untouched | ✓ | T4 final README: ``` | Role | URL | |---------|------------------------------------------------| | Primary | [https://live-tunnel-abc123.trycloudflare.com](https://live-tunnel-abc123.trycloudflare.com) | ``` (Backup row gone; prose `https://live-tunnel-abc123.trycloudflare.com` refreshed in place.) **Edge cases handled:** logs with no URL, dead/rotating URL mid-transition (refused until it actually 200s), multiple historical URLs in logs, markdown rows with URL in both text and href, prose mentions, missing README, missing `.coding-hermes/` dir (auto-created), and double-run idempotency. **Not verifiable in this sandbox:** a real trycloudflare endpoint (no network access to one) and the actual git repo — the script's write targets and grep pipeline were validated on exact-match fixtures, and the script refuses (exit 1/2) rather than guessing when the live tunnel can't be confirmed, which is the safe failure mode for production. ---
{"model": "deepseek-v4-flash", "problem_class": "docs-tunnel-url-rotation", "result": "passed", "tests": 9}