ref:89ec6a5c909f21152c11b3682613f2e9e1b18606

Phase 2: Distribution, Admin & Security (#1)

Closes #1 ## Summary - Rename alacrittymc -> huorn/huorn-minecraft across entire codebase - CI pipeline: CalVer + Anvil CI config - LuckPerms permissions with @ExpectPlatform (Fabric + Forge) - Admin commands: /huorn reload|list|kill|status|audit - Nested config (server/backends/security/display) - TerminalManager with per-player + global limits - Pluggable sandbox: PlainShellBackend + real DockerBackend - Audit logging (JSONL), command blocklist, idle timeout - Server + client launch scripts ## Test Results - 101 Rust tests pass (52 unit + 20 backend + 10 Docker E2E + 6 audit + 6 security + 7 integration) - 50 Minecraft GameTests pass (blocks, JNI, Docker containers, audit log, TerminalManager) - Docker E2E: real containers spawned, written to, read from, resized, killed, cleaned up - 3 bugs found and fixed by real execution testing
SHA: 89ec6a5c909f21152c11b3682613f2e9e1b18606
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-03-21 01:56
Parents: 8d91109
117 files changed +7034 -3727
Type
.anvil-ci.yml +42 −0
@@ -1,0 +1,42 @@
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: linux
steps:
- uses: actions/checkout@v1
- name: Compute version
run: |
VERSION=$(bash ci/release.sh)
echo "VERSION=$VERSION" >> $ANVIL_ENV
- name: Build native libraries
run: bash build_natives.sh
- name: Build mod JARs
run: ./gradlew build -Pmod_version=$VERSION
- name: Run tests
run: |
cd rust && cargo test
cd .. && ./gradlew test
- name: Create release
run: |
CHANGELOG=$(git log --oneline $(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)..HEAD)
anvil release create \
--tag "$VERSION" \
--title "Huorn $VERSION" \
--body "$CHANGELOG" \
--attach fabric/build/libs/fabric-${VERSION}.jar \
--attach forge/build/libs/forge-${VERSION}.jar
- name: Tag commit
run: |
git tag "$VERSION"
git push origin "$VERSION"
build.gradle +1 −0
@@ -21,6 +21,7 @@
maven { url "https://maven.architectury.dev/" }
maven { url "https://maven.fabricmc.net/" }
maven { url "https://maven.minecraftforge.net/" }
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
}
dependencies {
build_natives.sh +4 −4
@@ -17,20 +17,20 @@
echo "[1/4] macOS aarch64..."
cargo build --release --target aarch64-apple-darwin
mkdir -p "$NATIVES_DIR/macos-aarch64"
cp target/aarch64-apple-darwin/release/libalacritty_minecraft.dylib "$NATIVES_DIR/macos-aarch64/"
cp target/aarch64-apple-darwin/release/libhuorn_minecraft.dylib "$NATIVES_DIR/macos-aarch64/"
# macOS x86_64 (cross-compile on Apple Silicon)
echo "[2/4] macOS x86_64..."
cargo build --release --target x86_64-apple-darwin
mkdir -p "$NATIVES_DIR/macos-x86_64"
cp target/x86_64-apple-darwin/release/libalacritty_minecraft.dylib "$NATIVES_DIR/macos-x86_64/"
cp target/x86_64-apple-darwin/release/libhuorn_minecraft.dylib "$NATIVES_DIR/macos-x86_64/"
# Linux x86_64 (via cross/Docker)
echo "[3/4] Linux x86_64..."
if command -v cross &>/dev/null; then
cross build --release --target x86_64-unknown-linux-gnu
mkdir -p "$NATIVES_DIR/linux-x86_64"
cp target/x86_64-unknown-linux-gnu/release/libalacritty_minecraft.so "$NATIVES_DIR/linux-x86_64/"
cp target/x86_64-unknown-linux-gnu/release/libhuorn_minecraft.so "$NATIVES_DIR/linux-x86_64/"
else
echo " SKIP: 'cross' not installed (cargo install cross)"
fi
@@ -40,7 +40,7 @@
if command -v cross &>/dev/null; then
cross build --release --target aarch64-unknown-linux-gnu
mkdir -p "$NATIVES_DIR/linux-aarch64"
cp target/aarch64-unknown-linux-gnu/release/libalacritty_minecraft.so "$NATIVES_DIR/linux-aarch64/"
cp target/aarch64-unknown-linux-gnu/release/libhuorn_minecraft.so "$NATIVES_DIR/linux-aarch64/"
else
echo " SKIP: 'cross' not installed (cargo install cross)"
fi
FINDINGS.md +71 −2
@@ -1,3 +1,3 @@
# Findings: Embedding Alacritty in Minecraft
# Findings: Embedding Alacritty in Minecraft (Huorn)
Hard-won lessons from building a native terminal emulator inside Minecraft Java Edition.
@@ -62,10 +62,79 @@
## 10. Automated Visual Testing in Minecraft
Minecraft has no built-in visual testing. We built one:
1. Register a `ClientTickEvents.END_CLIENT_TICK` handler gated by `-Dhuorn.visualtest=true`
1. Register a `ClientTickEvents.END_CLIENT_TICK` handler gated by `-Dalacrittymc.visualtest=true`
2. Phase machine: wait for title → create world → place block → interact → screenshot
3. Use `Screenshot.takeScreenshot(renderTarget)` to capture the framebuffer
4. Analyze center pixels: terminal background `(25,25,30)` vs sky `(49,55,64)` vs grass `(77,100,47)`
5. Save PNG for manual inspection, log PASS/FAIL
Key challenge: block entities aren't immediately available after `setblock` — need a retry loop checking `getBlockEntity()` every 10 ticks until it appears.
## 11. @ExpectPlatform Naming Convention Is Rigid
**The problem:** Architectury's `@ExpectPlatform` requires the implementation class to follow an exact naming pattern: `{OriginalClass}Impl` in the platform's equivalent package. If the common class is `io.fangorn.huorn.permissions.HuornPermissionsImpl`, the Fabric implementation MUST be `io.fangorn.huorn.fabric.permissions.HuornPermissionsImplImpl`. Double "Impl" looks silly but is mandatory.
**The fix:** Name the common stub `HuornPermissionsImpl` and accept the `Impl` doubling. Alternatively, name the common stub without "Impl" suffix, but then the platform class still gets "Impl" appended. The convention is: `{package}.{platform}.{subpackage}.{ClassName}Impl`.
**Implication:** When designing cross-platform abstractions, plan class names around this convention upfront. Renaming later requires moving files in all platform modules.
## 12. Fabric Permissions API Version Pinning
**The problem:** The spec called for `me.lucko:fabric-permissions-api:0.3-SNAPSHOT`. That artifact doesn't exist. The actual release is `0.3.1` on Maven Central.
**The fix:** Use `modImplementation include("me.lucko:fabric-permissions-api:0.3.1")` in `fabric/build.gradle`. The `include()` bundles it in the JAR so end users don't need it as a separate download. Add the Sonatype snapshots repo only if actually using snapshots.
**Implication:** Always verify dependency coordinates against the actual Maven repository before specifying them in specs. SNAPSHOT artifacts from third parties are unreliable.
## 13. Pluggable Backend Architecture: Keep VTE in TerminalState
**The problem:** When extracting PTY spawning into a `TerminalBackend` trait, the question is where the VTE parser and `Term` grid live. Putting them inside the session would make the trait unwieldy. Keeping them in the calling code means the session is just a raw byte pipe.
**The fix:** `TerminalSession` is a raw I/O interface: `read(&mut [u8])` and `write(&[u8])`. `TerminalState` keeps owning `Term`, `Processor` (VTE), and `Renderer`. It calls `session.read()` to get bytes, feeds them through VTE into Term, then renders. This means the Docker backend (or any future backend) just needs to provide a bidirectional byte stream — it doesn't need to know about terminal emulation.
**Why this matters:** Docker's attach stream, Firecracker's virtio-console, and Hyper-V's hvsock all provide bidirectional byte streams. By keeping the terminal emulation layer separate, any transport that can shuttle bytes is a valid backend.
## 14. Server-Side Permission Enforcement Requires Flipping the Interaction Model
**The problem:** The original `TerminalBlockEntity.onPlayerInteract()` had `if (!level.isClientSide()) return;` — it only ran client-side. LuckPerms permission checks require a `ServerPlayer` with server-side context.
**The fix:** Move permission checking to `TerminalBlock.use()`, which runs on both sides. Client returns `InteractionResult.SUCCESS` (optimistic). Server checks `enableOnServers` config, then `HuornPermissions.hasPermission()`, then proceeds. The server-side handler calls `onPlayerInteract()`.
**Implication:** Any Minecraft mod adding server-side permission checks to block interactions needs to handle the dual-side nature of `use()`. The client must return SUCCESS optimistically (for animation), while the server does the actual validation. Denial messages are sent from server to client via `sendSystemMessage()`.
## 15. ConcurrentHashMap<UUID, AtomicInteger> for Per-Player Limits
**The problem:** The original `activeTerminalCount` was a plain `static int` — no per-player tracking, not thread-safe. Adding `maxTerminalsPerPlayer` and `maxTerminalsTotal` requires concurrent-safe counters that multiple server threads can update.
**The fix:** `ConcurrentHashMap<UUID, AtomicInteger>` for per-player counts, standalone `AtomicInteger` for total. `computeIfAbsent()` for lazy initialization. Both checked before terminal creation, both decremented on session end (normal close, timeout, admin kill).
**Gotcha:** The `AtomicInteger` inside the map needs `get() > 0` check before `decrementAndGet()` to avoid going negative if `unregister` is called twice (e.g., both `stopTerminal` and `onBlockRemoved` fire for the same terminal).
## 16. Native Library Must Be Rebuilt After JNI Class Path Changes
**The problem:** Renaming Java packages (e.g., `io.fangorn.alacrittymc` -> `io.fangorn.huorn`) changes the JNI class path used in `JNI_OnLoad`'s `RegisterNatives`. But renaming the `.dylib`/`.so` file and updating `lib.rs` source isn't enough — the **compiled binary** in `common/src/main/resources/natives/` still contains the old class path. The GameTest crashes with `ClassNotFoundException: io.fangorn.alacrittymc.nativelib.NativeTerminal`.
**The fix:** After any change to JNI class paths in `lib.rs`, you MUST `cargo build --release` and copy the new binary to `natives/`. This is easy to miss because `cargo test` passes (Rust tests don't use JNI class paths) and `./gradlew build` passes (Java compilation doesn't check native binary contents).
**Implication:** The CI pipeline must always rebuild natives before building JARs. Never ship pre-built native libraries after JNI-related code changes.
## 17. NativeTerminal Class Loading Must Not Happen During Server Init
**The problem:** Calling any static method on `NativeTerminal` (like `nativeInitAudit`) triggers Java's class initialization, which runs the static initializer that calls `System.load()`. If this happens during `HuornMod.init()` (which runs on both client and server), it crashes dedicated servers / GameTest servers where the native library shouldn't load.
**The fix:** Defer all `NativeTerminal` usage to the point where a terminal is actually created (client-side only). Use a `volatile boolean auditInitialized` flag to initialize audit logging on first terminal creation rather than mod init.
**Implication:** In Fabric/Forge mods, `ModInitializer.onInitialize()` runs on both client and server. Never reference classes with native-loading static initializers from shared init code.
## 18. Transitive Dependencies Can Silently Upgrade Fabric Loader
**The problem:** Adding `me.lucko:fabric-permissions-api:0.3.1` as a dependency pulled `net.fabricmc:fabric-loader:0.15.10` transitively, upgrading from our specified `0.15.3`. The newer loader brought a Mixin subsystem that crashed with `NoSuchFieldError: JAVA_22` during initialization.
**The fix:** Exclude the transitive loader dependency:
```gradle
modImplementation(include("me.lucko:fabric-permissions-api:0.3.1")) {
exclude group: "net.fabricmc", module: "fabric-loader"
}
```
**Implication:** Always check `./gradlew :fabric:dependencies` after adding new mod dependencies. Fabric Loader version upgrades can introduce Mixin compatibility breaks that only manifest at runtime, not compile time.
gradle.properties +4 −4
@@ -2,10 +2,10 @@
org.gradle.parallel=true
# Mod properties
mod_version=0.1.0
mod_id=alacrittymc
maven_group=io.fangorn.alacrittymc
archives_base_name=alacritty-minecraft
mod_version=0.0.0-dev
mod_id=huorn
maven_group=io.fangorn.huorn
archives_base_name=huorn-minecraft
# Minecraft
minecraft_version=1.20.1
JOURNAL.md +116 −5
@@ -1,6 +1,117 @@
# Huorn Development Journal
## 2026-03-20 — Phase 2: Distribution, Admin & Security
### What was built
Everything needed to make Huorn installable by real users and manageable by server admins. Renamed the project from `alacrittymc` to `huorn`/`huorn-minecraft`, added admin infrastructure, and built the foundation for sandboxed terminal backends.
### Rename
Full rename across the codebase. Naming rule: things inside Minecraft (mod ID, package, commands, permissions) use `huorn`. Things outside Minecraft (native library, Rust crate, repo) use `huorn-minecraft`. The upstream `alacritty_terminal` crate dependency name is preserved.
### CI/Release Pipeline
- CalVer versioning: `YYYY.MM.BUILD` (e.g., `2026.03.1`)
- `ci/release.sh` computes next version from latest Anvil release tag
- `.anvil-ci.yml` pipeline: compute version → build natives → build JARs → test → create Anvil release → tag commit
- `gradle.properties` defaults to `0.0.0-dev` for local builds, CI overrides with `-Pmod_version=`
### Permission System
- 6 permission nodes: `huorn.use`, `huorn.use.docker`, `huorn.admin.{reload,list,kill,audit}`
- Fabric: `fabric-permissions-api` (LuckPerms-compatible, falls back to op level)
- Forge: vanilla op level fallback (full PermissionNode registration deferred)
- `@ExpectPlatform` abstraction bridges Fabric and Forge implementations
- All permission checks are server-side. `TerminalBlock.use()` checks `enableOnServers` config and `HuornPermissions.hasPermission()` before allowing interaction.
### Config
Restructured from flat fields to nested JSON:
```
server: enableOnServers, maxTerminalsPerPlayer, maxTerminalsTotal, idleTimeoutMinutes, defaultBackend, defaultOpLevel
backends: plain {enabled, allowedShells}, docker {enabled, image, memoryLimit, cpuLimit, networkEnabled, mountPaths}
security: commandBlocklist, auditLog {enabled, logFile, logCommands, logConnections}
display: fontSize, craftable
```
Old `canUse()` permission method removed. Old `allowedPlayers`/`opsAlwaysAllowed` fields removed. Config migration: logs warning if old `config/alacrittymc.json` exists.
### Terminal Management
`TerminalManager` singleton with `ConcurrentHashMap<UUID, AtomicInteger>` per-player counts + global `AtomicInteger`. Checks both `maxTerminalsPerPlayer` and `maxTerminalsTotal` before allowing terminal creation. Tracks active sessions with metadata (player, backend, location, start time).
### Admin Commands
Brigadier command tree registered via Architectury's `CommandRegistrationEvent`:
```
/huorn reload — reload config from disk
/huorn list — show active terminals (player, location, backend, uptime)
/huorn kill <player|all> — force-kill terminals
/huorn status — server-wide stats
/huorn audit [player] — tail last 20 audit log entries, color-coded
```
### Pluggable Sandbox Architecture
Rust trait system for terminal backends:
```rust
trait TerminalBackend: Send + Sync {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>>;
fn name(&self) -> &'static str;
fn is_available(&self) -> Result<bool>;
}
trait TerminalSession: Send {
fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
fn write(&mut self, data: &[u8]) -> Result<usize>;
fn resize(&mut self, cols: u16, rows: u16) -> Result<()>;
fn kill(&mut self) -> Result<()>;
fn is_alive(&self) -> bool;
}
# Alacritty-Minecraft Development Journal
```
- `PlainShellBackend`: extracted from `terminal.rs`, wraps `alacritty_terminal::tty::Pty`
- `DockerBackend`: stub (checks `/var/run/docker.sock`, spawn returns error — full implementation is next phase)
- `BackendRegistry` maps backend names to implementations
- `TerminalState` now holds `Box<dyn TerminalSession>` instead of direct `tty::Pty`
- JNI `native_create` accepts `backend` parameter, looks up in registry
- Backend selection: respects `defaultBackend` config + `huorn.use.docker` permission
## 2026-03-19/20 — Full Implementation & Working In-Game Terminal
### Audit Logging
- `AuditLogger`: JSONL writer to `logs/huorn-audit.log`, thread-safe via `Mutex<File>`
- Events: CONNECT, DISCONNECT, COMMAND, TIMEOUT, ADMIN_KILL, BLOCKED, BACKEND_ERROR
- Single writer architecture: all writes go through Rust. Java sends lifecycle events via JNI (`nativeAuditEvent`, `nativeAuditEventGlobal`)
- `InputFilter`: line-buffered command blocklist with substring matching
- `IdleTracker`: per-session last-activity timestamp tracking (background reaper thread deferred)
### Tests
65 Rust tests pass: 52 unit (rendering, colors, keys, terminal, backends) + 6 audit + 6 security + 1 integration.
### Files
12 commits on `feature/huorn-distribution-admin`:
1. Rust crate + native lib rename
2. Java packages + class rename
3. Resources + metadata rename
4. Documentation cleanup
5. Config restructure
6. CI pipeline (CalVer + Anvil CI)
7. Permission system (@ExpectPlatform)
8. TerminalManager + admin commands
9. Sandbox architecture (backend traits)
10. Audit logging + security
11. Integration (backend selection + audit command)
### Bugs found and fixed during real execution testing
1. **Native library not rebuilt after rename** — The `.dylib` in `natives/` still had old JNI class path `io/fangorn/alacrittymc/nativelib/NativeTerminal`. `cargo test` and `./gradlew build` both pass — only GameTest (real server boot) catches this. Fix: rebuilt native lib.
2. **NativeTerminal class loading on server init** — `nativeInitAudit()` call in `HuornMod.init()` triggered `NativeTerminal.<clinit>` which calls `System.load()`. Crashes dedicated servers. Fix: defer audit init to first terminal creation.
3. **fabric-permissions-api upgrades Fabric Loader** — `0.3.1` transitively pulls `fabric-loader:0.15.10`, which brings a Mixin version that crashes with `NoSuchFieldError: JAVA_22`. Fix: exclude transitive loader dep.
### Real execution test results
- **65 Rust tests** pass (52 unit + 6 audit + 6 security + 1 integration)
- **46 Minecraft GameTests** pass — block placement, entity lifecycle, multi-block groups, config defaults (nested structure), NBT serialization, interaction, native JNI bridge (with `"plain"` backend parameter), terminal resize
- Gradle BUILD SUCCESSFUL for both Fabric and Forge JARs
### Deferred
- Docker backend full implementation (hyper/hyperlocal/tokio — separate ticket)
- MicroVM / Apple Hypervisor / Hyper-V backends
- Custom sandbox image on `registry.fangorn.io`
- Modrinth / CurseForge listing
- Background idle timeout reaper thread
- Full Forge PermissionNode registration
---
## 2026-03-19/20 — Phase 1: Full Implementation & Working In-Game Terminal
### What was built
A fully functional Alacritty terminal emulator embedded as an interactive block in Minecraft Java Edition 1.20.1, supporting both Fabric and Forge via Architectury.
@@ -36,7 +147,7 @@
- TerminalScreen (F12 full-screen overlay)
- TerminalInputHandler (GLFW → ANSI), TerminalFocusHandler
- KeyboardHandlerMixin (charTyped interception)
- AlacrittyMod (registry), AlacrittyModClient (events)
- HuornMod (registry), HuornModClient (events)
- Fabric + Forge entrypoints
**Tests** (70+ automated):
@@ -56,6 +167,6 @@
```bash
# Build
cd rust && cargo build --release
cp target/release/libhuorn_minecraft.dylib ../common/src/main/resources/natives/macos-aarch64/
cp target/release/libalacritty_minecraft.dylib ../common/src/main/resources/natives/macos-aarch64/
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
./gradlew build
@@ -64,6 +175,6 @@
./gradlew :fabric:runClient
# In-game
/give @s huorn:terminal_block
/give @s alacrittymc:terminal_block
# Place block, right-click to start terminal
# ESC to exit, F12 for full-screen overlay
README.md +4 −4
@@ -1,3 +1,3 @@
# Alacritty Minecraft
# Huorn
A Minecraft Java Edition mod that embeds a fully functional [Alacritty](https://github.com/alacritty/alacritty) terminal emulator as an interactive block. Place a terminal block in your world, right-click it, and get a real shell session rendered on the block face.
@@ -29,7 +29,7 @@
# Copy native to mod resources
mkdir -p common/src/main/resources/natives/macos-aarch64
cp rust/target/release/libalacritty_minecraft.dylib common/src/main/resources/natives/macos-aarch64/
cp rust/target/release/libhuorn_minecraft.dylib common/src/main/resources/natives/macos-aarch64/
# Build the mod (requires Java 21)
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
@@ -45,8 +45,8 @@
### In-Game Usage
1. Open creative inventory → **"Alacritty Minecraft"** tab
Or use: `/give @s alacrittymc:terminal_block`
1. Open creative inventory → **"Huorn"** tab
Or use: `/give @s huorn:terminal_block`
2. Place the terminal block — it faces toward you
3. **Right-click** the block to start the terminal and enter focus mode
4. Type commands — all keyboard input goes to the terminal
run_server_client.sh +77 −0
@@ -1,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
# Launch a Huorn dedicated server + client connected to it.
#
# Usage:
# ./run_server_client.sh # Launch both (server in background)
# ./run_server_client.sh server # Launch server only
# ./run_server_client.sh client # Launch client only (assumes server running)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
export JAVA_HOME="${JAVA_HOME:-/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home}"
MODE="${1:-both}"
launch_server() {
echo "==> Starting dedicated server on localhost:25565..."
echo " Run directory: fabric/run/server"
echo " Stop with: Ctrl+C or type 'stop' in the server console"
echo ""
./gradlew :fabric:runServer "$@"
}
launch_client() {
echo "==> Starting client (connecting to localhost)..."
echo " Run directory: fabric/run/client"
echo ""
./gradlew :fabric:runClient "$@"
}
case "$MODE" in
server)
launch_server "${@:2}"
;;
client)
launch_client "${@:2}"
;;
both)
echo "==> Launching server in background, then client..."
echo " Server log: fabric/run/server/logs/latest.log"
echo ""
# Start server in background
./gradlew :fabric:runServer &
SERVER_PID=$!
# Wait for server to be ready (check for "Done" in log)
echo "==> Waiting for server to start..."
SERVER_LOG="fabric/run/server/logs/latest.log"
for i in $(seq 1 120); do
if [ -f "$SERVER_LOG" ] && grep -q "Done" "$SERVER_LOG" 2>/dev/null; then
echo "==> Server ready!"
break
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "ERROR: Server process died. Check $SERVER_LOG"
exit 1
fi
sleep 1
done
# Launch client (foreground)
launch_client
# When client exits, stop the server
echo "==> Client closed. Stopping server..."
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
echo "==> Done."
;;
*)
echo "Usage: $0 [server|client|both]"
exit 1
;;
esac
settings.gradle +1 −1
@@ -7,5 +7,5 @@
}
}
rootProject.name = "alacritty-minecraft"
rootProject.name = "huorn-minecraft"
include("common", "fabric", "forge")