ref:main

Huorn Distribution, Admin & Security Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make Huorn installable by end users and manageable by server admins — rename, CI pipeline, permissions, commands, pluggable sandbox (plain + Docker), and audit logging.

Architecture: Pipeline-first approach. Rename first (clean slate), then CI (dogfood distribution from day one), then admin layer (config, permissions, commands), then sandbox architecture (trait extraction + Docker), then audit logging. Each phase produces working, testable software.

Tech Stack: Java 17, Minecraft 1.20.1, Architectury 9.2.14, Fabric Permissions API, Forge PermissionAPI, Brigadier, Rust (alacritty_terminal 0.25, hyper, hyperlocal), Docker Engine API, JSONL.

Spec: docs/superpowers/specs/2026-03-20-huorn-distribution-admin-design.md


File Structure

New Files

# CI / Release
ci/ # New directory
release.sh # CalVer version-bump + release script
.anvil-ci.yml # Anvil CI pipeline config
# Java — Common Module (post-rename paths)
common/src/main/java/io/fangorn/huorn/
permissions/
HuornPermissions.java # Permission node constants + check API
HuornPermissionsImpl.java # @ExpectPlatform stub (common side)
command/
HuornCommand.java # Root /huorn command, registers subcommands
ReloadCommand.java # /huorn reload
ListCommand.java # /huorn list
KillCommand.java # /huorn kill <player|all>
StatusCommand.java # /huorn status
AuditCommand.java # /huorn audit [player] [--last N]
service/
TerminalManager.java # Centralized terminal tracking (per-player + global counts)
# Java — Fabric Platform
fabric/src/main/java/io/fangorn/huorn/fabric/
permissions/
HuornPermissionsImplImpl.java # Fabric Permissions API implementation
# Java — Forge Platform
forge/src/main/java/io/fangorn/huorn/forge/
permissions/
HuornPermissionsImplImpl.java # Forge PermissionAPI implementation
# Rust — Backend Module
rust/src/
backend/
mod.rs # TerminalBackend + TerminalSession traits, BackendRegistry
plain.rs # PlainShellBackend (extracted from terminal.rs)
docker.rs # DockerBackend (Docker Engine API over unix socket)
audit.rs # AuditLogger: JSONL writer, single-file append
security.rs # InputFilter: line accumulation + blocklist matching
# Tests
rust/tests/
backend_integration.rs # Backend trait contract tests
audit_test.rs # Audit log format + rotation-safe tests
security_test.rs # Blocklist matching tests

Modified Files (post-rename paths)

# Rust
rust/Cargo.toml # Crate name, [lib] name, new deps (hyper, hyperlocal, chrono, serde_json)
rust/src/lib.rs # JNI class path, new native functions (audit, backend param)
rust/src/terminal.rs # Hold Box<dyn TerminalSession> instead of owning PTY directly
# Java — Common
common/src/main/java/io/fangorn/huorn/
HuornMod.java # Register commands, load config
config/HuornConfig.java # Restructured nested config
nativelib/NativeLoader.java # Library filename: libhuorn_minecraft.*
nativelib/NativeTerminal.java # New native methods (audit, backend param on create)
block/TerminalBlock.java # Permission check via HuornPermissions
block/TerminalBlockEntity.java # Use TerminalManager, server-side interaction, backend field in NBT
client/HuornModClient.java # (renamed from AlacrittyModClient)
# Build
build_natives.sh # Output filenames libhuorn_minecraft.*
gradle.properties # mod_id=huorn, version injection support
common/build.gradle # fabric-permissions-api dependency
fabric/build.gradle # (dependency updates if needed)
# Resources (all renamed from alacrittymc -> huorn)
common/src/main/resources/
huorn.mixins.json # Package references
huorn.accesswidener
assets/huorn/... # Blockstates, models, lang, textures
data/huorn/... # Recipes, loot tables
fabric/src/main/resources/fabric.mod.json
forge/src/main/resources/META-INF/mods.toml

Phase 1: Rename

Task 1: Rename Rust Crate and Native Library

Files:

  • Modify: rust/Cargo.toml

  • Modify: rust/src/lib.rs (JNI_OnLoad class path)

  • Modify: build_natives.sh (output filenames)

  • Modify: common/src/main/java/io/fangorn/alacrittymc/nativelib/NativeLoader.java (library filename)

  • Step 1: Update Cargo.toml crate name and lib name

In rust/Cargo.toml, change:

[package]
name = "huorn-minecraft"
[lib]
name = "huorn_minecraft"
crate-type = ["cdylib"]
  • Step 2: Update JNI_OnLoad class path in lib.rs

In rust/src/lib.rs, change the RegisterNatives class path from io/fangorn/alacrittymc/nativelib/NativeTerminal to io/fangorn/huorn/nativelib/NativeTerminal.

  • Step 3: Update build_natives.sh output filenames

Replace all occurrences of libalacritty_minecraft with libhuorn_minecraft and alacritty_minecraft with huorn_minecraft (for Windows DLL).

  • Step 4: Update NativeLoader.java library filename

In NativeLoader.java, change libraryFileName() to return libhuorn_minecraft.dylib, libhuorn_minecraft.so, and huorn_minecraft.dll.

  • Step 5: Rename native library files in resources
cd common/src/main/resources/natives
mv macos-aarch64/libalacritty_minecraft.dylib macos-aarch64/libhuorn_minecraft.dylib
mv macos-x86_64/libalacritty_minecraft.dylib macos-x86_64/libhuorn_minecraft.dylib
mv linux-x86_64/libalacritty_minecraft.so linux-x86_64/libhuorn_minecraft.so
mv linux-aarch64/libalacritty_minecraft.so linux-aarch64/libhuorn_minecraft.so
mv windows-x86_64/alacritty_minecraft.dll windows-x86_64/huorn_minecraft.dll
  • Step 6: Verify Rust builds
cd rust && cargo build && cargo test

Expected: All 40 tests pass, output is libhuorn_minecraft.dylib.

  • Step 7: Commit
git add -A && git commit -m "rename: Rust crate and native library to huorn-minecraft"

Task 2: Rename Java Packages and Mod ID

Files:

  • Move: common/src/main/java/io/fangorn/alacrittymc/ -> common/src/main/java/io/fangorn/huorn/

  • Move: fabric/src/main/java/io/fangorn/alacrittymc/ -> fabric/src/main/java/io/fangorn/huorn/

  • Move: forge/src/main/java/io/fangorn/alacrittymc/ -> forge/src/main/java/io/fangorn/huorn/

  • Modify: gradle.properties (mod_id)

  • Step 1: Move package directories

# Common
mkdir -p common/src/main/java/io/fangorn/huorn
mv common/src/main/java/io/fangorn/alacrittymc/* common/src/main/java/io/fangorn/huorn/
rmdir common/src/main/java/io/fangorn/alacrittymc
# Fabric
mkdir -p fabric/src/main/java/io/fangorn/huorn
mv fabric/src/main/java/io/fangorn/alacrittymc/* fabric/src/main/java/io/fangorn/huorn/
rmdir fabric/src/main/java/io/fangorn/alacrittymc
# Forge
mkdir -p forge/src/main/java/io/fangorn/huorn
mv forge/src/main/java/io/fangorn/alacrittymc/* forge/src/main/java/io/fangorn/huorn/
rmdir forge/src/main/java/io/fangorn/alacrittymc
  • Step 2: Find and replace package declarations and imports in all Java files
# Package declarations
find . -name "*.java" -exec sed -i '' 's/io\.fangorn\.alacrittymc/io.fangorn.huorn/g' {} +
# MOD_ID constant
find . -name "*.java" -exec sed -i '' 's/"alacrittymc"/"huorn"/g' {} +
  • Step 3: Rename class names

Rename in all Java files:

  • AlacrittyMod -> HuornMod
  • AlacrittyModClient -> HuornModClient
  • AlacrittyConfig -> HuornConfig
  • AlacrittyModFabric -> HuornModFabric
  • AlacrittyModFabricClient -> HuornModFabricClient
  • AlacrittyModForge -> HuornModForge

Then rename the files themselves to match.

  • Step 4: Update gradle.properties

Change mod_id=alacrittymc to mod_id=huorn. Change mod_name=Alacritty Minecraft to mod_name=Huorn. Change maven_group=io.fangorn.alacrittymc to maven_group=io.fangorn.huorn. Change archives_base_name=alacritty-minecraft to archives_base_name=huorn-minecraft.

  • Step 4a: Rename log message prefixes

Replace [AlacrittyMC] with [Huorn] in all Java and Rust files:

find . -name "*.java" -exec sed -i '' 's/\[AlacrittyMC\]/[Huorn]/g' {} +
find . -name "*.rs" -exec sed -i '' 's/\[AlacrittyMC\]/[Huorn]/g' {} +
  • Step 4b: Rename NativeLoader temp directory

In NativeLoader.java, change "alacrittymc-natives" to "huorn-natives".

  • Step 4c: Rename JNI test files in rust/tests/java/

Rename rust/tests/java/io/fangorn/alacrittymc/ to rust/tests/java/io/fangorn/huorn/ and update package declarations and System.loadLibrary calls within.

  • Step 5: Commit
git add -A && git commit -m "rename: Java packages and mod ID to huorn"

Task 3: Rename Resources and Metadata

Files:

  • Rename: common/src/main/resources/alacrittymc.mixins.json -> huorn.mixins.json

  • Rename: common/src/main/resources/alacrittymc.accesswidener -> huorn.accesswidener

  • Move: common/src/main/resources/assets/alacrittymc/ -> assets/huorn/

  • Move: common/src/main/resources/data/alacrittymc/ -> data/huorn/

  • Modify: fabric/src/main/resources/fabric.mod.json

  • Modify: forge/src/main/resources/META-INF/mods.toml

  • Modify: common/build.gradle (access widener reference)

  • Modify: common/src/main/resources/assets/huorn/lang/en_us.json

  • Step 1: Rename resource files and directories

cd common/src/main/resources
# Mixin config
mv alacrittymc.mixins.json huorn.mixins.json
# Access widener
mv alacrittymc.accesswidener huorn.accesswidener
# Asset directories
mv assets/alacrittymc assets/huorn
# Data directories
mv data/alacrittymc data/huorn
  • Step 2: Update contents of renamed resource files

In huorn.mixins.json: change "package": "io.fangorn.alacrittymc.mixin" to "package": "io.fangorn.huorn.mixin".

In assets/huorn/lang/en_us.json: change keys from block.alacrittymc.terminal_block to block.huorn.terminal_block, itemGroup.alacrittymc.main to itemGroup.huorn.main, update display name to "Huorn".

In recipes and loot tables: replace alacrittymc:terminal_block with huorn:terminal_block.

  • Step 3: Update fabric.mod.json

Change: "id": "alacrittymc" -> "id": "huorn", "name": "Alacritty Minecraft" -> "name": "Huorn", entrypoint class paths to io.fangorn.huorn.fabric.*, mixins to huorn.mixins.json.

  • Step 4: Update mods.toml

Change: modId="alacrittymc" -> modId="huorn", displayName="Alacritty Minecraft" -> displayName="Huorn". Also rename all [[dependencies.alacrittymc]] table headers to [[dependencies.huorn]].

  • Step 5: Update common/build.gradle access widener reference

Change alacrittymc.accesswidener to huorn.accesswidener.

  • Step 6: Update any remaining references in build.gradle files

Search all *.gradle files for alacrittymc and replace with huorn.

  • Step 7: Commit
git add -A && git commit -m "rename: resources and metadata to huorn"

Task 4: Verify Full Build After Rename

  • Step 1: Run Rust tests
cd rust && cargo test

Expected: All tests pass.

  • Step 2: Run Gradle build
./gradlew build

Expected: Both fabric and forge JARs build successfully.

  • Step 3: Verify JAR contents
jar tf fabric/build/libs/fabric-*.jar | grep -E "(huorn|alacritty)" | head -20

Expected: All paths reference huorn, no remaining alacrittymc references.

  • Step 4: Search for any remaining old references
rg -l "alacrittymc" --type java --type json --type toml --type rust
rg -l "AlacrittyMod\|AlacrittyConfig\|AlacrittyModClient" --type java

Expected: No matches (except comments explaining Alacritty is the upstream library).

  • Step 5: Commit any fixups, tag as rename-complete
git add -A && git commit -m "rename: verify and fix remaining alacrittymc references"

Phase 2: Config Restructure

Task 5: New HuornConfig with Nested Structure

Files:

  • Modify: common/src/main/java/io/fangorn/huorn/config/HuornConfig.java

  • Test: Manual verification — load default config, verify JSON structure

  • Step 1: Write test for config loading

Create a simple unit test (or main-method test if MC test infra is too heavy) that:

  • Loads a HuornConfig with no file present (defaults)

  • Verifies server.maxTerminalsPerPlayer == 4

  • Verifies server.maxTerminalsTotal == 32

  • Verifies server.defaultBackend == "plain"

  • Verifies backends.plain.enabled == true

  • Verifies backends.docker.enabled == false

  • Verifies security.auditLog.enabled == true

  • Step 2: Run test to verify it fails

Expected: Fails because HuornConfig still has flat structure.

  • Step 3: Rewrite HuornConfig with nested structure

Replace the flat HuornConfig class with nested inner classes:

public class HuornConfig {
public static final String CONFIG_PATH = "config/huorn.json";
public static final String OLD_CONFIG_PATH = "config/alacrittymc.json";
private static HuornConfig INSTANCE;
// Top-level sections
public ServerConfig server = new ServerConfig();
public BackendsConfig backends = new BackendsConfig();
public SecurityConfig security = new SecurityConfig();
public DisplayConfig display = new DisplayConfig();
public static class ServerConfig {
public boolean enableOnServers = false;
public int maxTerminalsPerPlayer = 4;
public int maxTerminalsTotal = 32;
public int idleTimeoutMinutes = 30;
public String defaultBackend = "plain";
public int defaultOpLevel = 4;
}
public static class BackendsConfig {
public PlainBackendConfig plain = new PlainBackendConfig();
public DockerBackendConfig docker = new DockerBackendConfig();
}
public static class PlainBackendConfig {
public boolean enabled = true;
public List<String> allowedShells = List.of("/bin/bash", "/bin/zsh");
}
public static class DockerBackendConfig {
public boolean enabled = false;
public String image = "ubuntu:24.04";
public String memoryLimit = "256m";
public double cpuLimit = 0.5;
public boolean networkEnabled = false;
public List<String> mountPaths = new ArrayList<>();
}
public static class SecurityConfig {
public List<String> commandBlocklist = List.of("rm -rf /", ":(){ :|:& };:");
public AuditLogConfig auditLog = new AuditLogConfig();
}
public static class AuditLogConfig {
public boolean enabled = true;
public String logFile = "logs/huorn-audit.log";
public boolean logCommands = true;
public boolean logConnections = true;
}
public static class DisplayConfig {
public float fontSize = 14.0f;
public boolean craftable = true;
}
// load(), save(), getInstance(), reload() methods
// Old canUse() method REMOVED — replaced by HuornPermissions
}
  • Step 4: Add config migration warning

In load(), after loading the new config, check if config/alacrittymc.json exists. If it does, log a warning:

if (Files.exists(Path.of(OLD_CONFIG_PATH))) {
LOGGER.warn("[Huorn] Found old config file '{}'. Please migrate settings to '{}' manually.",
OLD_CONFIG_PATH, CONFIG_PATH);
}
  • Step 5: Update all callers of the old config API

Search for AlacrittyConfig.getInstance() and config.canUse() calls. Update to use new nested structure (e.g., HuornConfig.getInstance().server.maxTerminalsPerPlayer). Remove canUse() calls — will be replaced by permission checks in Task 8.

  • Step 6: Run test to verify it passes

Expected: Config test passes with correct defaults.

  • Step 7: Run Gradle build
./gradlew build

Expected: Compiles. Existing game tests may need updating for renamed config.

  • Step 8: Commit
git add -A && git commit -m "feat: restructure HuornConfig with nested sections"

Phase 3: CI/Release Pipeline

Task 6: CalVer Version-Bump Script

Files:

  • Create: ci/release.sh

  • Step 1: Write version-bump script

#!/usr/bin/env bash
set -euo pipefail
# Compute next CalVer version: YYYY.MM.BUILD
# Reads latest Anvil release tag, increments build number
# Resets build to 1 on new month
# Defaults to YYYY.MM.1 if no prior release exists
ANVIL="/Users/chaos/src/notifd_src/anvil_cli/target/release/anvil"
YEAR_MONTH=$(date +"%Y.%m")
# Get latest release tag (may fail if no releases exist)
LATEST=$($ANVIL release list --format json 2>/dev/null | jq -r '.[0].tag // empty' || true)
if [ -z "$LATEST" ]; then
echo "${YEAR_MONTH}.1"
exit 0
fi
# Parse existing tag
LATEST_YM=$(echo "$LATEST" | cut -d. -f1-2)
LATEST_BUILD=$(echo "$LATEST" | cut -d. -f3)
if [ "$LATEST_YM" = "$YEAR_MONTH" ]; then
echo "${YEAR_MONTH}.$((LATEST_BUILD + 1))"
else
echo "${YEAR_MONTH}.1"
fi
  • Step 2: Test version-bump script locally
chmod +x ci/release.sh && bash ci/release.sh

Expected: Outputs something like 2026.03.1 (or incremented if releases exist).

  • Step 3: Commit
git add ci/release.sh && git commit -m "feat: add CalVer version-bump script"

Task 7: Anvil CI Configuration

Files:

  • Create: .anvil-ci.yml

  • Modify: gradle.properties (version injection)

  • Modify: build.gradle (accept -Pversion)

  • Step 1: Update gradle.properties for version injection

Change mod_version=0.1.0 to mod_version=0.0.0-dev (default for local builds). The CI pipeline overrides this via -Pmod_version=YYYY.MM.BUILD.

  • Step 2: Ensure build.gradle reads version from property

Verify build.gradle already uses project.mod_version or equivalent. If it uses a hardcoded version, change it to read from gradle.properties.

  • Step 3: Write Anvil CI config
# .anvil-ci.yml
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"

Note: This is a starting point. Anvil CI’s actual config format may differ — adapt to whatever anvil ci supports. The key flow (version -> build natives -> build JARs -> test -> release -> tag) is what matters.

  • Step 4: Commit
git add .anvil-ci.yml gradle.properties build.gradle && git commit -m "feat: add Anvil CI pipeline with CalVer releases"

Phase 4: Permission System

Task 8: Permission Abstraction (Common Module)

Files:

  • Create: common/src/main/java/io/fangorn/huorn/permissions/HuornPermissions.java

  • Create: common/src/main/java/io/fangorn/huorn/permissions/HuornPermissionsImpl.java

  • Step 1: Write HuornPermissions constants and API

package io.fangorn.huorn.permissions;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissions {
// Permission node constants
public static final String USE = "huorn.use";
public static final String USE_DOCKER = "huorn.use.docker";
public static final String ADMIN_RELOAD = "huorn.admin.reload";
public static final String ADMIN_LIST = "huorn.admin.list";
public static final String ADMIN_KILL = "huorn.admin.kill";
public static final String ADMIN_AUDIT = "huorn.admin.audit";
/**
* Check if a player has a permission.
* Delegates to platform-specific implementation.
* Falls back to op level check if no permissions mod installed.
*/
public static boolean hasPermission(ServerPlayer player, String permission) {
return HuornPermissionsImpl.check(player, permission);
}
}
  • Step 2: Write @ExpectPlatform stub
package io.fangorn.huorn.permissions;
import dev.architectury.injectables.annotations.ExpectPlatform;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissionsImpl {
@ExpectPlatform
public static boolean check(ServerPlayer player, String permission) {
throw new AssertionError("Platform implementation missing");
}
}
  • Step 3: Commit
git add -A && git commit -m "feat: add permission abstraction with @ExpectPlatform"

Task 9: Fabric Permissions Implementation

Files:

  • Create: fabric/src/main/java/io/fangorn/huorn/fabric/permissions/HuornPermissionsImplImpl.java

  • Modify: common/build.gradle or fabric/build.gradle (add fabric-permissions-api dependency)

  • Step 1: Add fabric-permissions-api dependency

In fabric/build.gradle, add:

modImplementation "me.lucko:fabric-permissions-api:0.3-SNAPSHOT"

Or if Architectury provides it transitively via Fabric API, verify it’s already available.

  • Step 2: Write Fabric permissions implementation
package io.fangorn.huorn.fabric.permissions;
import io.fangorn.huorn.config.HuornConfig;
import me.lucko.fabric.api.permissions.v0.Permissions;
import net.minecraft.server.level.ServerPlayer;
public class HuornPermissionsImplImpl {
public static boolean check(ServerPlayer player, String permission) {
int defaultOpLevel = HuornConfig.getInstance().server.defaultOpLevel;
return Permissions.check(player, permission, defaultOpLevel);
}
}

The Fabric Permissions API’s Permissions.check(player, permission, defaultOpLevel) checks LuckPerms first, falls back to op level if no permissions mod is installed.

  • Step 3: Commit
git add -A && git commit -m "feat: Fabric permissions via fabric-permissions-api"

Task 10: Forge Permissions Implementation

Files:

  • Create: forge/src/main/java/io/fangorn/huorn/forge/permissions/HuornPermissionsImplImpl.java

  • Step 1: Write Forge permissions implementation

package io.fangorn.huorn.forge.permissions;
import io.fangorn.huorn.config.HuornConfig;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.server.permission.PermissionAPI;
import net.minecraftforge.server.permission.nodes.PermissionNode;
import net.minecraftforge.server.permission.nodes.PermissionTypes;
public class HuornPermissionsImplImpl {
// Register permission nodes on mod init
// Forge requires explicit node registration unlike Fabric's string-based API
public static boolean check(ServerPlayer player, String permission) {
// Forge PermissionAPI requires registered PermissionNode objects
// For now, fall back to op level check
int defaultOpLevel = HuornConfig.getInstance().server.defaultOpLevel;
return player.hasPermissions(defaultOpLevel);
}
}

Note: Forge’s PermissionAPI requires registering PermissionNode objects at mod init time. The full implementation needs PermissionNode<Boolean> objects registered in HuornModForge.init(). The above is a fallback that works immediately; full Forge permission node registration can be added as a follow-up step.

  • Step 2: Commit
git add -A && git commit -m "feat: Forge permissions with op-level fallback"

Task 11: Server-Side Permission Enforcement

Files:

  • Modify: common/src/main/java/io/fangorn/huorn/block/TerminalBlock.java

  • Modify: common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java

  • Step 1: Refactor TerminalBlock.use() to enforce permissions server-side

Currently TerminalBlockEntity.onPlayerInteract() has if (level == null || !level.isClientSide()) return; — it only runs client-side. This must be flipped: permission checks run server-side, and the server triggers the client.

In TerminalBlock.use():

@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide()) {
return InteractionResult.SUCCESS; // Client: optimistic, let server decide
}
// Server-side: block on dedicated servers if not enabled
if (player instanceof ServerPlayer serverPlayer) {
if (serverPlayer.getServer().isDedicatedServer()
&& !HuornConfig.getInstance().server.enableOnServers) {
serverPlayer.sendSystemMessage(Component.literal("Terminals are disabled on this server."));
return InteractionResult.FAIL;
}
// Permission check
if (!HuornPermissions.hasPermission(serverPlayer, HuornPermissions.USE)) {
serverPlayer.sendSystemMessage(Component.literal("You don't have permission to use terminals."));
return InteractionResult.FAIL;
}
}
// Server-side: start terminal, notify client
TerminalBlockEntity entity = (TerminalBlockEntity) level.getBlockEntity(pos);
if (entity != null) {
entity.onPlayerInteract(player);
}
return InteractionResult.CONSUME;
}
  • Step 2: Remove old canUse() calls from TerminalBlock and TerminalBlockEntity

Delete any remaining AlacrittyConfig.canUse() / HuornConfig.canUse() calls. The permission check is now in TerminalBlock.use() via HuornPermissions.

  • Step 3: Verify Gradle builds
./gradlew build
  • Step 4: Commit
git add -A && git commit -m "feat: server-side permission enforcement via HuornPermissions"

Phase 5: Terminal Management Service

Task 12: TerminalManager

Files:

  • Create: common/src/main/java/io/fangorn/huorn/service/TerminalManager.java

  • Test: Unit test for TerminalManager counting logic

  • Step 1: Write test for TerminalManager

Test that:

  • canCreateTerminal(uuid) returns true when under both limits

  • canCreateTerminal(uuid) returns false when player at per-player limit

  • canCreateTerminal(uuid) returns false when server at total limit

  • registerTerminal(uuid) increments both counters

  • unregisterTerminal(uuid) decrements both counters

  • Thread-safe: concurrent register/unregister doesn’t corrupt counts

  • Step 2: Run test to verify it fails

Expected: TerminalManager class doesn’t exist yet.

  • Step 3: Implement TerminalManager
package io.fangorn.huorn.service;
import io.fangorn.huorn.config.HuornConfig;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class TerminalManager {
private static final TerminalManager INSTANCE = new TerminalManager();
private final ConcurrentHashMap<UUID, AtomicInteger> perPlayerCounts = new ConcurrentHashMap<>();
private final AtomicInteger totalCount = new AtomicInteger(0);
// Track active sessions: sessionId -> metadata (player, location, backend, start time)
private final ConcurrentHashMap<Long, SessionInfo> activeSessions = new ConcurrentHashMap<>();
public static TerminalManager getInstance() { return INSTANCE; }
public boolean canCreateTerminal(UUID playerUuid) {
HuornConfig config = HuornConfig.getInstance();
if (totalCount.get() >= config.server.maxTerminalsTotal) return false;
AtomicInteger playerCount = perPlayerCounts.get(playerUuid);
if (playerCount != null && playerCount.get() >= config.server.maxTerminalsPerPlayer) return false;
return true;
}
public void registerTerminal(UUID playerUuid, long sessionHandle, SessionInfo info) {
perPlayerCounts.computeIfAbsent(playerUuid, k -> new AtomicInteger(0)).incrementAndGet();
totalCount.incrementAndGet();
activeSessions.put(sessionHandle, info);
}
public void unregisterTerminal(UUID playerUuid, long sessionHandle) {
AtomicInteger count = perPlayerCounts.get(playerUuid);
if (count != null) count.decrementAndGet();
totalCount.decrementAndGet();
activeSessions.remove(sessionHandle);
}
public ConcurrentHashMap<Long, SessionInfo> getActiveSessions() {
return activeSessions;
}
public int getTotalCount() { return totalCount.get(); }
public int getPlayerCount(UUID playerUuid) {
AtomicInteger count = perPlayerCounts.get(playerUuid);
return count != null ? count.get() : 0;
}
public record SessionInfo(UUID playerUuid, String playerName, String backend,
String location, long startTimeMs) {}
}
  • Step 4: Run test to verify it passes

  • Step 5: Integrate TerminalManager into TerminalBlockEntity

In TerminalBlockEntity.startTerminalIfNeeded():

  • Before creating terminal: check TerminalManager.getInstance().canCreateTerminal(player.getUUID())

  • After creating terminal: call TerminalManager.getInstance().registerTerminal(uuid, handle, info)

  • In stopTerminal() and onBlockRemoved(): call TerminalManager.getInstance().unregisterTerminal(uuid, handle)

  • Step 6: Verify Gradle builds

./gradlew build
  • Step 7: Commit
git add -A && git commit -m "feat: TerminalManager with per-player and global terminal tracking"

Phase 6: Admin Commands

Task 13: Root Command + Reload

Files:

  • Create: common/src/main/java/io/fangorn/huorn/command/HuornCommand.java

  • Create: common/src/main/java/io/fangorn/huorn/command/ReloadCommand.java

  • Modify: common/src/main/java/io/fangorn/huorn/HuornMod.java (register commands)

  • Step 1: Write HuornCommand root

package io.fangorn.huorn.command;
import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
public class HuornCommand {
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
// Start with just ReloadCommand. ListCommand, KillCommand, StatusCommand
// added in Task 14. AuditCommand added in Task 15.
dispatcher.register(Commands.literal("huorn")
.then(ReloadCommand.register())
);
}
}
  • Step 2: Write ReloadCommand
package io.fangorn.huorn.command;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import io.fangorn.huorn.config.HuornConfig;
import io.fangorn.huorn.permissions.HuornPermissions;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
public class ReloadCommand {
public static LiteralArgumentBuilder<CommandSourceStack> register() {
return Commands.literal("reload")
.requires(source -> {
if (source.getEntity() instanceof ServerPlayer player) {
return HuornPermissions.hasPermission(player, HuornPermissions.ADMIN_RELOAD);
}
return source.hasPermission(4); // Console always allowed
})
.executes(context -> {
HuornConfig.load();
context.getSource().sendSuccess(
() -> Component.literal("[Huorn] Config reloaded."), true);
return 1;
});
}
}
  • Step 3: Register commands in HuornMod.init()

Use Architectury’s command registration event:

CommandRegistrationEvent.EVENT.register((dispatcher, registryAccess, environment) -> {
HuornCommand.register(dispatcher);
});
  • Step 4: Verify Gradle builds
./gradlew build
  • Step 5: Commit
git add -A && git commit -m "feat: /huorn command root + /huorn reload"

Task 14: List, Kill, Status Commands

Files:

  • Create: common/src/main/java/io/fangorn/huorn/command/ListCommand.java

  • Create: common/src/main/java/io/fangorn/huorn/command/KillCommand.java

  • Create: common/src/main/java/io/fangorn/huorn/command/StatusCommand.java

  • Step 1: Write ListCommand

Lists all active terminals from TerminalManager.getActiveSessions(). Shows: player name, location, backend, uptime. Permission: huorn.admin.list.

  • Step 2: Write KillCommand

Accepts <player> argument (or all). Looks up sessions for that player in TerminalManager, calls NativeTerminal.close() on each, unregisters them. Permission: huorn.admin.kill.

  • Step 3: Write StatusCommand

Shows: total active terminals, per-backend counts, which backends are available (calls is_available() via JNI when sandbox architecture is wired — for now, just reports plain shell as available). Permission: none (informational, available to all ops).

  • Step 4: Verify Gradle builds
./gradlew build
  • Step 5: Commit
git add -A && git commit -m "feat: /huorn list, kill, status commands"

Task 15: Audit Command (Placeholder)

Files:

  • Create: common/src/main/java/io/fangorn/huorn/command/AuditCommand.java

  • Step 1: Write AuditCommand

Reads logs/huorn-audit.log line by line, parses JSONL, filters by player name and --last N. For now, this is a placeholder that reports “Audit log not yet available” if the file doesn’t exist. Full implementation after Task 25 (audit logging).

  • Step 2: Commit
git add -A && git commit -m "feat: /huorn audit command (placeholder, reads log file)"

Phase 7: Pluggable Sandbox Architecture

Task 16: Backend Traits in Rust

Files:

  • Create: rust/src/backend/mod.rs

  • Modify: rust/Cargo.toml (no new deps yet, just module declaration)

  • Step 1: Write failing test for backend trait contract

In rust/tests/backend_integration.rs:

use huorn_minecraft::backend::{TerminalBackend, TerminalSession, BackendConfig, BackendRegistry};
#[test]
fn test_backend_registry_has_plain() {
let registry = BackendRegistry::new();
assert!(registry.get("plain").is_some());
}
#[test]
fn test_plain_backend_is_available() {
let registry = BackendRegistry::new();
let plain = registry.get("plain").unwrap();
assert!(plain.is_available().unwrap());
}
#[test]
fn test_plain_backend_name() {
let registry = BackendRegistry::new();
let plain = registry.get("plain").unwrap();
assert_eq!(plain.name(), "plain");
}
  • Step 2: Run test to verify it fails
cd rust && cargo test --test backend_integration

Expected: Fails — module doesn’t exist.

  • Step 3: Write backend traits and registry
// rust/src/backend/mod.rs
pub mod plain;
pub mod docker;
use std::collections::HashMap;
pub struct BackendConfig {
pub cols: u16,
pub rows: u16,
pub font_size: f32,
pub shell: String,
pub working_dir: String,
// Docker-specific
pub image: Option<String>,
pub memory_limit: Option<String>,
pub cpu_limit: Option<f64>,
pub network_enabled: Option<bool>,
}
pub trait TerminalBackend: Send + Sync {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String>;
fn name(&self) -> &'static str;
fn is_available(&self) -> Result<bool, String>;
}
pub trait TerminalSession: Send {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, String>;
fn write(&mut self, data: &[u8]) -> Result<usize, String>;
fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String>;
fn kill(&mut self) -> Result<(), String>;
fn is_alive(&self) -> bool;
}
pub struct BackendRegistry {
backends: HashMap<String, Box<dyn TerminalBackend>>,
}
impl BackendRegistry {
pub fn new() -> Self {
let mut backends: HashMap<String, Box<dyn TerminalBackend>> = HashMap::new();
backends.insert("plain".to_string(), Box::new(plain::PlainShellBackend));
// Docker registered but may not be available
backends.insert("docker".to_string(), Box::new(docker::DockerBackend));
Self { backends }
}
pub fn get(&self, name: &str) -> Option<&dyn TerminalBackend> {
self.backends.get(name).map(|b| b.as_ref())
}
pub fn available_backends(&self) -> Vec<&str> {
self.backends.values()
.filter(|b| b.is_available().unwrap_or(false))
.map(|b| b.name())
.collect()
}
}
  • Step 4: Run test to verify it passes
cd rust && cargo test --test backend_integration
  • Step 5: Commit
git add -A && git commit -m "feat: TerminalBackend + TerminalSession traits with BackendRegistry"

Task 17: PlainShellBackend (Extract from terminal.rs)

Files:

  • Create: rust/src/backend/plain.rs

  • Modify: rust/src/terminal.rs (use Box<dyn TerminalSession> instead of direct PTY)

  • Step 1: Write failing test for PlainShellBackend spawn

#[test]
fn test_plain_shell_spawn_and_write() {
let backend = PlainShellBackend;
let config = BackendConfig {
cols: 80, rows: 24, font_size: 14.0,
shell: "/bin/bash".to_string(),
working_dir: "/tmp".to_string(),
image: None, memory_limit: None, cpu_limit: None, network_enabled: None,
};
let mut session = backend.spawn(&config).unwrap();
assert!(session.is_alive());
session.write(b"echo hello\n").unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
let mut buf = [0u8; 1024];
let n = session.read(&mut buf).unwrap_or(0);
assert!(n > 0); // Should have output
session.kill().unwrap();
}
  • Step 2: Run test to verify it fails

  • Step 3: Implement PlainShellBackend

Extract the PTY spawning logic from TerminalState::new() in terminal.rs into PlainShellBackend.spawn(). The PlainShellSession struct wraps alacritty_terminal::tty::Pty and implements TerminalSession.

// rust/src/backend/plain.rs
use super::{BackendConfig, TerminalBackend, TerminalSession};
use alacritty_terminal::tty;
use std::path::PathBuf;
pub struct PlainShellBackend;
impl TerminalBackend for PlainShellBackend {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String> {
// Create PTY using alacritty_terminal::tty
// (extract from TerminalState::new())
let pty = create_pty(config)?;
Ok(Box::new(PlainShellSession { pty, alive: true }))
}
fn name(&self) -> &'static str { "plain" }
fn is_available(&self) -> Result<bool, String> {
// Check if any allowed shell exists
Ok(PathBuf::from("/bin/bash").exists() || PathBuf::from("/bin/zsh").exists())
}
}
struct PlainShellSession {
pty: tty::Pty,
alive: bool,
}
impl TerminalSession for PlainShellSession {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, String> { /* read from PTY fd */ }
fn write(&mut self, data: &[u8]) -> Result<usize, String> { /* write to PTY fd */ }
fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String> { /* ioctl TIOCSWINSZ */ }
fn kill(&mut self) -> Result<(), String> { /* SIGKILL child, close fd */ }
fn is_alive(&self) -> bool { self.alive }
}
  • Step 4: Refactor terminal.rs to use Box

Replace pty: Option<tty::Pty> in TerminalState with session: Option<Box<dyn TerminalSession>>. Update poll_pty(), send_text(), send_key() to route through session.read() and session.write().

  • Step 5: Run all Rust tests
cd rust && cargo test

Expected: All existing 40+ tests still pass, plus new backend tests.

  • Step 6: Commit
git add -A && git commit -m "feat: extract PlainShellBackend from terminal.rs"

Task 18: JNI Backend Parameter

Files:

  • Modify: rust/src/lib.rs (add backend parameter to native_create)

  • Modify: common/src/main/java/io/fangorn/huorn/nativelib/NativeTerminal.java

  • Step 1: Add backend parameter to native_create in lib.rs

Change native_create(cols, rows, fontSize, shell, workingDir) to native_create(cols, rows, fontSize, shell, workingDir, backend). Look up backend in BackendRegistry, pass to TerminalState::new().

  • Step 2: Update NativeTerminal.java

Add String backend parameter to constructor and native method declaration. Default to "plain" if null.

  • Step 3: Update TerminalBlockEntity to pass backend

Read backend from config (HuornConfig.getInstance().server.defaultBackend) and pass to NativeTerminal constructor.

  • Step 4: Re-register native method in JNI_OnLoad

Update the method signature in the NativeMethod array to include the new String parameter.

  • Step 5: Run Rust tests + Gradle build
cd rust && cargo test && cd .. && ./gradlew build
  • Step 6: Commit
git add -A && git commit -m "feat: JNI backend parameter for terminal creation"

Task 19: DockerBackend

Files:

  • Modify: rust/Cargo.toml (add hyper, hyperlocal, tokio, serde_json)

  • Create: rust/src/backend/docker.rs

  • Step 1: Add Docker dependencies to Cargo.toml

[dependencies]
hyper = { version = "1", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] }
hyperlocal = "0.9"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util"] }
serde_json = "1"
  • Step 2: Write failing test for DockerBackend availability
#[test]
fn test_docker_backend_is_available_checks_socket() {
let backend = DockerBackend;
let result = backend.is_available();
// On CI without Docker: Ok(false)
// On dev machine with Docker: Ok(true)
assert!(result.is_ok());
}
  • Step 3: Implement DockerBackend
// rust/src/backend/docker.rs
pub struct DockerBackend;
impl TerminalBackend for DockerBackend {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String> {
let image = config.image.as_deref().unwrap_or("ubuntu:24.04");
let container_id = create_container(image, config)?;
start_container(&container_id)?;
let stream = attach_container(&container_id)?;
Ok(Box::new(DockerSession { container_id, stream, alive: true }))
}
fn name(&self) -> &'static str { "docker" }
fn is_available(&self) -> Result<bool, String> {
// Check /var/run/docker.sock exists and daemon responds to /_ping
check_docker_daemon()
}
}

Key implementation details:

  • create_container(): POST to /containers/create with JSON body specifying image, TTY, shell command, resource limits (HostConfig.Memory, HostConfig.NanoCpus, HostConfig.NetworkMode)

  • start_container(): POST to /containers/{id}/start

  • attach_container(): POST to /containers/{id}/attach?stream=1&stdin=1&stdout=1&stderr=1 — returns bidirectional stream

  • DockerSession.read/write: Read/write the attach stream

  • DockerSession.kill/Drop: POST to /containers/{id}/kill, then DELETE /containers/{id}

  • Step 4: Run tests

cd rust && cargo test

Expected: Docker availability test passes (returns Ok(false) if no Docker, Ok(true) if Docker present). Spawn test only passes if Docker is available (mark with #[ignore] for CI).

  • Step 5: Commit
git add -A && git commit -m "feat: DockerBackend via Docker Engine API over unix socket"

Phase 8: Audit Logging & Security

Task 20: Audit Log Writer in Rust

Files:

  • Create: rust/src/audit.rs

  • Modify: rust/Cargo.toml (add chrono)

  • Test: rust/tests/audit_test.rs

  • Step 1: Write failing test for audit log format

#[test]
fn test_audit_log_writes_jsonl() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut logger = AuditLogger::new(tmp.path().to_str().unwrap()).unwrap();
logger.log_event("CONNECT", r#"{"player":"abc-123","name":"Steve","backend":"plain","location":"world:1,2,3"}"#);
let contents = std::fs::read_to_string(tmp.path()).unwrap();
let line: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
assert_eq!(line["event"], "CONNECT");
assert!(line["ts"].as_str().unwrap().contains("2026"));
}
  • Step 2: Run test to verify it fails

  • Step 3: Implement AuditLogger

// rust/src/audit.rs
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::Mutex;
pub struct AuditLogger {
file: Mutex<File>,
}
impl AuditLogger {
pub fn new(path: &str) -> Result<Self, String> {
let file = OpenOptions::new()
.create(true).append(true).open(path)
.map_err(|e| format!("Failed to open audit log: {}", e))?;
Ok(Self { file: Mutex::new(file) })
}
pub fn log_event(&self, event_type: &str, payload_json: &str) {
let ts = chrono::Utc::now().to_rfc3339();
let line = format!(r#"{{"ts":"{}","event":"{}",{}}}"#,
ts, event_type, payload_json.trim_start_matches('{').trim_end_matches('}'));
if let Ok(mut f) = self.file.lock() {
let _ = writeln!(f, "{}", line);
}
}
}
  • Step 4: Run test to verify it passes

  • Step 5: Commit

git add -A && git commit -m "feat: AuditLogger with JSONL format"

Task 21: JNI Audit Functions

Files:

  • Modify: rust/src/lib.rs (add native_audit_event and native_audit_event_global)

  • Modify: common/src/main/java/io/fangorn/huorn/nativelib/NativeTerminal.java

  • Step 1: Add audit JNI functions to lib.rs

fn native_audit_event(
mut env: JNIEnv, _class: JClass,
handle: jlong, event_type: JString, payload: JString
) {
// Look up session by handle, log with session context
let event: String = env.get_string(&event_type).unwrap().into();
let json: String = env.get_string(&payload).unwrap().into();
if let Some(logger) = GLOBAL_AUDIT_LOGGER.get() {
logger.log_event(&event, &json);
}
}
fn native_audit_event_global(
mut env: JNIEnv, _class: JClass,
event_type: JString, payload: JString
) {
// Log without session context (BACKEND_ERROR, ADMIN_KILL on dead session)
let event: String = env.get_string(&event_type).unwrap().into();
let json: String = env.get_string(&payload).unwrap().into();
if let Some(logger) = GLOBAL_AUDIT_LOGGER.get() {
logger.log_event(&event, &json);
}
}

Use OnceLock<AuditLogger> as the global logger, initialized on first call or via a new native_init_audit(logPath) JNI function called from Java at server startup.

  • Step 2: Register new native methods in JNI_OnLoad

Add native_audit_event and native_audit_event_global to the NativeMethod array.

  • Step 3: Add audit methods to NativeTerminal.java
public static native void nativeAuditEvent(long handle, String eventType, String jsonPayload);
public static native void nativeAuditEventGlobal(String eventType, String jsonPayload);
public static native void nativeInitAudit(String logPath);
  • Step 4: Call nativeInitAudit from HuornMod.init()
String logPath = HuornConfig.getInstance().security.auditLog.logFile;
if (HuornConfig.getInstance().security.auditLog.enabled) {
NativeTerminal.nativeInitAudit(logPath);
}
  • Step 5: Run tests + build
cd rust && cargo test && cd .. && ./gradlew build
  • Step 6: Commit
git add -A && git commit -m "feat: JNI audit functions (session-bound + global)"

Task 22: Command Blocklist (Input Filter)

Files:

  • Create: rust/src/security.rs

  • Test: rust/tests/security_test.rs

  • Step 1: Write failing tests for blocklist matching

#[test]
fn test_blocklist_blocks_exact_match() {
let filter = InputFilter::new(vec!["rm -rf /".to_string()]);
assert_eq!(filter.check_line("rm -rf /"), FilterResult::Blocked("rm -rf /".to_string()));
}
#[test]
fn test_blocklist_allows_safe_input() {
let filter = InputFilter::new(vec!["rm -rf /".to_string()]);
assert_eq!(filter.check_line("ls -la"), FilterResult::Allowed);
}
#[test]
fn test_line_accumulation() {
let mut filter = InputFilter::new(vec!["rm -rf /".to_string()]);
filter.feed(b"rm -r");
assert_eq!(filter.pending_result(), None); // Not a complete line yet
filter.feed(b"f /\n");
assert_eq!(filter.pending_result(), Some(FilterResult::Blocked("rm -rf /".to_string())));
}
  • Step 2: Run tests to verify they fail

  • Step 3: Implement InputFilter

// rust/src/security.rs
pub struct InputFilter {
blocklist: Vec<String>,
line_buffer: Vec<u8>,
}
pub enum FilterResult {
Allowed,
Blocked(String),
}
impl InputFilter {
pub fn new(blocklist: Vec<String>) -> Self {
Self { blocklist, line_buffer: Vec::new() }
}
pub fn feed(&mut self, data: &[u8]) -> Vec<FilterResult> {
let mut results = Vec::new();
for &byte in data {
if byte == b'\r' || byte == b'\n' {
if !self.line_buffer.is_empty() {
let line = String::from_utf8_lossy(&self.line_buffer).to_string();
results.push(self.check_line(&line));
self.line_buffer.clear();
}
} else {
self.line_buffer.push(byte);
}
}
results
}
pub fn check_line(&self, line: &str) -> FilterResult {
for pattern in &self.blocklist {
if line.contains(pattern.as_str()) {
return FilterResult::Blocked(pattern.clone());
}
}
FilterResult::Allowed
}
}
  • Step 4: Run tests to verify they pass

  • Step 5: Wire InputFilter into TerminalSession wrapper

Create an AuditedSession wrapper struct that wraps any Box<dyn TerminalSession>, intercepts write() calls to feed through InputFilter, and logs COMMAND and BLOCKED events to the AuditLogger.

  • Step 6: Run all tests
cd rust && cargo test
  • Step 7: Commit
git add -A && git commit -m "feat: command blocklist with line accumulation"

Task 23: Idle Timeout

Files:

  • Modify: rust/src/backend/mod.rs or create rust/src/timeout.rs

  • Step 1: Write failing test for idle timeout detection

#[test]
fn test_idle_timeout_detects_idle_session() {
let mut tracker = IdleTracker::new(Duration::from_secs(1)); // 1s for testing
tracker.register(1);
std::thread::sleep(Duration::from_secs(2));
let expired = tracker.check_expired();
assert_eq!(expired, vec![1]);
}
#[test]
fn test_idle_timeout_resets_on_activity() {
let mut tracker = IdleTracker::new(Duration::from_secs(2));
tracker.register(1);
std::thread::sleep(Duration::from_secs(1));
tracker.touch(1); // Activity
std::thread::sleep(Duration::from_secs(1));
let expired = tracker.check_expired();
assert!(expired.is_empty()); // Not expired — was touched
}
  • Step 2: Run tests to verify they fail

  • Step 3: Implement IdleTracker

use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct IdleTracker {
timeout: Duration,
sessions: HashMap<u64, Instant>, // session_id -> last_activity
}
impl IdleTracker {
pub fn new(timeout: Duration) -> Self {
Self { timeout, sessions: HashMap::new() }
}
pub fn register(&mut self, session_id: u64) {
self.sessions.insert(session_id, Instant::now());
}
pub fn touch(&mut self, session_id: u64) {
if let Some(ts) = self.sessions.get_mut(&session_id) {
*ts = Instant::now();
}
}
pub fn unregister(&mut self, session_id: u64) {
self.sessions.remove(&session_id);
}
pub fn check_expired(&self) -> Vec<u64> {
let now = Instant::now();
self.sessions.iter()
.filter(|(_, ts)| now.duration_since(**ts) > self.timeout)
.map(|(id, _)| *id)
.collect()
}
}
  • Step 4: Wire into session lifecycle

The AuditedSession wrapper calls tracker.touch(id) on every write(). A background thread (spawned on native_init_audit or a new native_init_security JNI call) runs check_expired() every 60 seconds and calls kill() on expired sessions, logging TIMEOUT events.

  • Step 5: Run tests to verify they pass

  • Step 6: Commit

git add -A && git commit -m "feat: idle timeout with background reaper thread"

Task 24: Wire Audit Events from Java Lifecycle

Files:

  • Modify: common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java

  • Modify: common/src/main/java/io/fangorn/huorn/command/KillCommand.java

  • Modify: common/src/main/java/io/fangorn/huorn/service/TerminalManager.java

  • Step 1: Add CONNECT event on terminal start

In TerminalBlockEntity.startTerminalIfNeeded(), after successfully creating the terminal:

if (HuornConfig.getInstance().security.auditLog.logConnections) {
String payload = String.format(
"{\"player\":\"%s\",\"name\":\"%s\",\"backend\":\"%s\",\"location\":\"%s\"}",
player.getStringUUID(), player.getName().getString(),
backend, worldPosition.toShortString());
NativeTerminal.nativeAuditEvent(terminal.getHandle(), "CONNECT", payload);
}
  • Step 2: Add DISCONNECT event on terminal stop

In TerminalBlockEntity.stopTerminal():

if (HuornConfig.getInstance().security.auditLog.logConnections) {
long duration = System.currentTimeMillis() - sessionStartTime;
String payload = String.format("{\"player\":\"%s\",\"name\":\"%s\",\"duration_ms\":%d}",
playerUuid, playerName, duration);
NativeTerminal.nativeAuditEvent(terminal.getHandle(), "DISCONNECT", payload);
}
  • Step 3: Add ADMIN_KILL event in KillCommand
String payload = String.format("{\"admin\":\"%s\",\"target_player\":\"%s\"}",
source.getTextName(), targetPlayerName);
NativeTerminal.nativeAuditEventGlobal("ADMIN_KILL", payload);
  • Step 4: Add BACKEND_ERROR event on spawn failure

In TerminalBlockEntity.startTerminalIfNeeded(), in the catch block:

NativeTerminal.nativeAuditEventGlobal("BACKEND_ERROR",
String.format("{\"backend\":\"%s\",\"error\":\"%s\"}", backend, e.getMessage()));
  • Step 5: Verify Gradle builds
./gradlew build
  • Step 6: Commit
git add -A && git commit -m "feat: wire audit events from Java lifecycle (CONNECT, DISCONNECT, ADMIN_KILL, BACKEND_ERROR)"

Phase 9: Integration & Polish

Task 25: Backend Selection via Config + Permissions

Files:

  • Modify: common/src/main/java/io/fangorn/huorn/block/TerminalBlockEntity.java

  • Step 1: Implement backend resolution logic

In startTerminalIfNeeded(), before creating the terminal:

private String resolveBackend(ServerPlayer player) {
HuornConfig config = HuornConfig.getInstance();
String preferred = config.server.defaultBackend; // "plain" or "docker"
// If default is docker: use it only if enabled AND player has permission
if ("docker".equals(preferred) && config.backends.docker.enabled
&& HuornPermissions.hasPermission(player, HuornPermissions.USE_DOCKER)) {
return "docker";
}
// If default is plain (or docker wasn't available): use plain if enabled
if (config.backends.plain.enabled) {
return "plain";
}
// Last resort: try docker if enabled and player has permission
if (config.backends.docker.enabled
&& HuornPermissions.hasPermission(player, HuornPermissions.USE_DOCKER)) {
return "docker";
}
// No backend available
return null;
}
  • Step 2: Wire into terminal creation

Use resolveBackend() result when creating NativeTerminal. If null, deny with message “No terminal backend available.”

  • Step 3: Persist backend in NBT

Save and load the backend name in TerminalBlockEntity.saveAdditional() / load() so it survives chunk unloads.

  • Step 4: Commit
git add -A && git commit -m "feat: backend resolution via config + permissions"

Task 26: /huorn audit Reads Log File

Files:

  • Modify: common/src/main/java/io/fangorn/huorn/command/AuditCommand.java

  • Step 1: Implement log file reader

Replace placeholder with actual JSONL reader:

// Read log file, parse each line as JSON, filter by player, limit to last N
Path logPath = Path.of(HuornConfig.getInstance().security.auditLog.logFile);
if (!Files.exists(logPath)) {
source.sendFailure(Component.literal("[Huorn] Audit log not found."));
return 0;
}
List<String> lines = Files.readAllLines(logPath);
// Apply --last N filter
// Apply player filter
// Format for in-game display
  • Step 2: Commit
git add -A && git commit -m "feat: /huorn audit reads and displays JSONL log"

Task 27: End-to-End Verification

  • Step 1: Run all Rust tests
cd rust && cargo test

Expected: All tests pass.

  • Step 2: Run Gradle build
./gradlew build

Expected: Both JARs build.

  • Step 3: Verify no remaining alacrittymc references
rg "alacrittymc" --type java --type json --type toml --type rust -l

Expected: No matches except upstream library references in comments.

  • Step 4: Verify config creates correct defaults

Launch the mod (dev environment), check that config/huorn.json is created with the full nested structure.

  • Step 5: Verify /huorn commands register

In-game or via console, run /huorn status. Expected: Shows terminal count and backend availability.

  • Step 6: Verify permission checks work

De-op a test player, try to use terminal block. Expected: “You don’t have permission” message.

  • Step 7: Final commit
git add -A && git commit -m "chore: end-to-end verification complete"