Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s

This commit is contained in:
2026-10-15 21:33:50 -04:00
parent c91d49c298
commit 8b372fa2c1
50 changed files with 4744 additions and 354 deletions
+17
View File
@@ -1,5 +1,22 @@
# Agent changelog
## 2026-10-15
### 1.11.0 — Private player moderation configuration
- Advertise `moderation.configure` and accept separate generation-bound game
credentials through private create/rebuild configuration or the typed
`instance.configure-moderation` operation. Existing servers preserve startup
through a fixed include in their verified private bank configuration; servers
without a verified bootstrap require rebuilding.
- Keep moderation credentials out of file reads, logs, manifests, staged changes,
shared recipes and operation receipts. Configure loads only the fixed private
file, never restarts gameplay, and does not replay RCON after an unknown result.
- Controller coverage remains unavailable until the game moderation runtime
connects and reports its capabilities. Older agents retain normal launches.
- Package release artifacts for offline signing before publication; do not
publish unsigned artifacts.
## 2026-10-14
### 1.10.1 — Retain verified rebuild artifacts (EC #154)
+113 -20
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.10.1"
VERSION = "1.11.0"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -49,6 +49,8 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -85,6 +87,7 @@ class AutoscalingInvalidated(Rejected):
OPERATION_ERROR_PHASES = {
"executing": "executing the operation",
"configuring-moderation": "configuring private game moderation",
"checking-instance": "checking the existing instance",
"validating-create-spec": "validating the container specification",
"preparing-create-directory": "preparing the instance directory",
@@ -401,13 +404,13 @@ def managed_open(root, value):
os.close(fd)
def private_bank_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
def private_instance_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH)
def require_public_file(value):
if private_bank_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations")
if private_instance_config(value):
raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
def managed_read(root, value):
@@ -457,7 +460,7 @@ def managed_manifest(root, payload, stopping):
def validate_paths():
for value in paths:
if private_bank_config(value):
if private_instance_config(value):
continue
check_budget()
try:
@@ -493,7 +496,7 @@ def managed_manifest(root, payload, stopping):
entries += 1
if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored or private_bank_config(relative + "/" + child.name):
if child.name in ignored or private_instance_config(relative + "/" + child.name):
continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode):
@@ -1571,7 +1574,7 @@ class Agent:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory()
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory()
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
@@ -1964,6 +1967,8 @@ class Agent:
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
self.operation_phase = "checking-instance"
if payload.get("moderation") is not None:
self.moderation_config(payload["moderation"], generation)
self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json"
@@ -2025,18 +2030,92 @@ class Agent:
for item in files:
require_public_file(item["path"])
bank = payload.get("bank")
if bank is None:
return files
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
raise Rejected("Invalid instance bank credential")
path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path")
# The game scripts use the public bank API; the host controller address
# is only for agent transport and must not become a game URL override.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
if bank is not None:
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
raise Rejected("Invalid instance bank credential")
path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path")
# Game transport uses its public URL, never the agent's enrollment URL.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
if payload.get("moderation") is not None:
content = self.moderation_config(payload["moderation"])
args = payload.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if not self.config_exec(args, "electric_cherry_moderation.cfg"):
raise Rejected("Moderation configuration must be loaded by the launch arguments")
files = files + [{"path": MODERATION_CONFIG_PATH, "contentBase64": base64.b64encode(content).decode()}]
return files
@staticmethod
def config_exec(args, filename):
return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None
@staticmethod
def moderation_config(moderation, generation=None):
if not isinstance(moderation, dict) or set(moderation) != {"token", "configPath", "url", "generation"}:
raise Rejected("Invalid moderation configuration")
token, url, target = moderation["token"], moderation["url"], moderation["generation"]
if not isinstance(token, str) or not re.fullmatch(r"ec_moderation_[A-Za-z0-9_-]{43}", token):
raise Rejected("Invalid instance moderation credential")
if type(target) is not int or target < 1 or generation is not None and target != generation:
raise Rejected("Moderation credential belongs to a different instance generation")
if moderation["configPath"] != MODERATION_CONFIG_PATH:
raise Rejected("Invalid private moderation config path")
if not isinstance(url, str) or len(url) > 2048 or re.search(r'[\s\x00-\x1f\x7f";\\]', url):
raise Rejected("Invalid moderation controller URL")
try:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/api/moderation/runtime" or parsed.query or parsed.fragment or parsed.port == 0:
raise ValueError()
except ValueError:
raise Rejected("Invalid moderation controller URL") from None
return f'set tws_moderation_url "{url}"\nset tws_moderation_token "{token}"\nset tws_moderation_generation "{target}"\n'.encode()
@staticmethod
def private_config_bytes(app, path):
with managed_open(app, path) as stream:
content = stream.read(8193)
if len(content) > 8192:
raise Rejected("Private instance configuration exceeds the supported size")
return content
def moderation_bootstrap(self, instance_id, generation):
saved = self.spec(instance_id, generation)
args = saved.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if self.config_exec(args, "electric_cherry_moderation.cfg"):
return None
if not self.config_exec(args, "electric_cherry_bank.cfg"):
raise Rejected("Rebuild this server before configuring moderation; startup cannot load the private configuration")
app = self.instance_dir(instance_id, generation) / "app"
try:
bank = self.private_config_bytes(app, "configs/electric_cherry_bank.cfg")
except FileNotFoundError:
raise Rejected("Rebuild this server before configuring moderation; private startup configuration is missing") from None
if not re.fullmatch(rb'set tws_bank_token "ec_bank_[A-Za-z0-9_-]{32,128}"\r?\n(?:exec electric_cherry_moderation.cfg\r?\n)?', bank):
raise Rejected("Rebuild this server before configuring moderation; private startup configuration cannot be verified")
return bank if bank.splitlines()[-1] == MODERATION_INCLUDE.rstrip(b"\n") else bank + MODERATION_INCLUDE
def configure_moderation(self, instance_id, generation, payload):
if set(payload) != {"moderation"}:
raise Rejected("Invalid moderation configuration request")
content = self.moderation_config(payload["moderation"], generation)
health = self.inspect(instance_id, generation)
if not health["exists"]:
raise Rejected("Instance does not exist")
bank = self.moderation_bootstrap(instance_id, generation)
app = self.instance_dir(instance_id, generation) / "app"
self.operation_phase = "configuring-moderation"
self.checkpoint({"phase": "configuring-moderation", "generation": generation})
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
managed_write(app, MODERATION_CONFIG_PATH, content, uid, gid)
if bank is not None:
managed_write(app, "configs/electric_cherry_bank.cfg", bank, uid, gid)
# Loading fixed dvars is not a restart. Never retain a reply which could
# echo credentials, and never replay the command after a lost response.
loaded = self.rcon(instance_id, generation, "exec electric_cherry_moderation.cfg").get("ok", False) if health["running"] else False
return {"configured": True, "generation": generation, "liveLoad": "sent" if loaded else "unconfirmed" if health["running"] else "deferred"}
def write_initial_files(self, root, prepared_files):
for path, content in prepared_files.items():
@@ -2169,6 +2248,8 @@ class Agent:
return self.create(instance_id, generation, payload)
if kind == "instance.inspect":
return self.inspect(instance_id, generation)
if kind == "instance.configure-moderation":
return self.configure_moderation(instance_id, generation, payload)
health = self.inspect(instance_id, generation)
name = self.container_name(instance_id, generation)
before = []
@@ -2198,6 +2279,8 @@ class Agent:
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable generation")
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
if spec.get("moderation") is not None:
self.moderation_config(spec["moderation"], next_generation)
# Keep the complete verified set on this call's stack only. Never
# mutate the immutable spec or cache bytes on the shared agent:
# held, failed and later operations must prepare their own files.
@@ -3001,6 +3084,16 @@ class Agent:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
if kind == "instance.configure-moderation":
current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text())
if current.get("generation") != generation or current.get("removed"):
return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed")
content = self.moderation_config(payload.get("moderation"), generation)
app = self.instance_dir(instance_id, generation) / "app"
bank = self.moderation_bootstrap(instance_id, generation)
if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank:
return row
return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True})
if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists():
+127 -2
View File
@@ -1650,10 +1650,12 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self):
def test_private_moderation_file_survives_stale_file_plans_without_snapshots(self):
self.test_private_bank_file_survives_stale_file_plans_without_snapshots(value=module.MODERATION_CONFIG_PATH)
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self, value="configs/electric_cherry_bank.cfg"):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
value = "configs/electric_cherry_bank.cfg"
target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret)
@@ -1707,6 +1709,129 @@ class AgentTests(unittest.TestCase):
with self.assertRaises(AgentError):
self.agent.initial_files(payload)
def moderation_configuration(self, generation=1):
return {"token": "ec_moderation_" + "m" * 43, "configPath": module.MODERATION_CONFIG_PATH,
"url": "https://public-controller.example/api/moderation/runtime", "generation": generation}
def create_moderation_bootstrap(self, *, direct=False, game="t5"):
payload = {"name": "server", "game": game, "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "rconPassword": "secret",
"env": {"PLUTAINER_EXTRA_ARGS": "+exec electric_cherry_bank.cfg +map_rotate"},
"bank": {"token": "ec_bank_" + "b" * 43, "configPath": "configs/electric_cherry_bank.cfg"}}
if direct:
payload["moderation"] = self.moderation_configuration()
payload["env"]["PLUTAINER_EXTRA_ARGS"] = "+exec electric_cherry_moderation.cfg +map_rotate"
row = self.agent.execute(self.operation("instance.create", payload, "create"))
self.assertEqual(row["state"], "completed", row)
return payload
def test_t6_create_materializes_private_moderation_configuration(self):
self.prepare_t6_library()
self.test_create_materializes_private_moderation_configuration(game="t6")
def test_create_materializes_private_moderation_configuration(self, game="t5"):
payload = self.create_moderation_bootstrap(direct=True, game=game)
target = self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH
self.assertEqual(target.read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
self.assertNotIn(self.config["controller"], target.read_text())
self.assertIn("moderation.configure", self.agent.inventory()["capabilities"])
self.assertNotIn(payload["moderation"]["token"], self.agent.journal.get("create")["result"])
def test_configure_moderation_persists_existing_startup_and_discards_rcon_secrets(self):
saved = self.create_moderation_bootstrap()
payload = {"moderation": self.moderation_configuration()}
operation = self.operation("instance.configure-moderation", payload)
with patch.object(self.agent, "rcon", return_value={"ok": True, "text": payload["moderation"]["token"]}) as command:
row = self.agent.execute(operation)
self.assertEqual(row["state"], "completed", row)
self.assertEqual(self.agent.execute(operation), row)
command.assert_called_once_with(INSTANCE, 1, "exec electric_cherry_moderation.cfg")
self.assertNotIn(payload["moderation"]["token"], row["result"])
app = self.agent.instance_dir(INSTANCE, 1) / "app"
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
bank = (app / "configs/electric_cherry_bank.cfg").read_bytes()
self.assertEqual(bank, f'set tws_bank_token "{saved["bank"]["token"]}"\n'.encode() + module.MODERATION_INCLUDE)
self.assertEqual(self.agent.spec(INSTANCE, 1), saved)
bank = bank.replace(b"\n", b"\r\n")
(app / "configs/electric_cherry_bank.cfg").write_bytes(bank)
with patch.object(self.agent, "rcon", return_value={"ok": False, "text": "timeout"}):
again = self.agent.execute(self.operation("instance.configure-moderation", payload, "configure-again"))
self.assertEqual(json.loads(again["result"])["liveLoad"], "unconfirmed")
self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_bytes(), bank)
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
def test_configure_moderation_rejects_unknown_startup_without_writing(self):
self.create()
with patch.object(self.agent, "rcon") as command:
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}))
self.assertEqual(row["state"], "failed", row)
self.assertIn("Rebuild this server", row["error"])
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
command.assert_not_called()
def test_configure_moderation_requires_readable_verified_private_startup(self):
self.create_moderation_bootstrap()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
bank = app / "configs/electric_cherry_bank.cfg"
for index, content in enumerate((None, b'unknown startup commands\n')):
with self.subTest(content=content), patch.object(self.agent, "rcon") as command:
bank.unlink(missing_ok=True)
if content is not None:
bank.write_bytes(content)
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}, f"invalid-bootstrap-{index}"))
self.assertEqual(row["state"], "failed", row)
self.assertIsNone(row["checkpoint"])
self.assertFalse((app / module.MODERATION_CONFIG_PATH).exists())
command.assert_not_called()
def test_configure_moderation_validates_credentials_generation_url_and_private_path(self):
self.create_moderation_bootstrap()
for index, change in enumerate((
{"token": 'ec_moderation_' + 'm' * 43 + '\nset other "1"'},
{"generation": 2}, {"generation": True}, {"configPath": "configs/other.cfg"},
{"url": "http://controller.example/api/moderation/runtime"},
{"url": "https://user:secret@controller.example/api/moderation/runtime"},
{"url": "https://controller.example/api/moderation/runtime?secret=1"},
{"url": 'https://controller.example/api/moderation/runtime";quit'},
{"unknown": "value"}
)):
with self.subTest(change=change), patch.object(self.agent, "rcon") as command:
payload = {"moderation": {**self.moderation_configuration(), **change}}
row = self.agent.execute(self.operation("instance.configure-moderation", payload, f"bad-moderation-{index}"))
self.assertEqual(row["state"], "failed", row)
self.assertIsNone(row["checkpoint"])
command.assert_not_called()
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
def test_configure_moderation_reconciles_written_configuration_without_replaying_rcon(self):
self.create_moderation_bootstrap()
operation = self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()})
with patch.object(self.agent, "rcon", side_effect=RuntimeError("secret must not leak")) as command:
unknown = self.agent.execute(operation)
self.assertEqual(unknown["state"], "unknown", unknown)
row = self.agent.execute(operation)
self.assertEqual(row["state"], "completed", row)
command.assert_called_once()
self.assertEqual(json.loads(row["result"])["liveLoad"], "unconfirmed")
self.assertNotIn("secret must not leak", str(unknown.get("error")))
def test_rebuild_rejects_moderation_credentials_for_the_previous_generation_before_disruption(self):
saved = self.create_moderation_bootstrap(direct=True)
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2}, "rebuild"))
self.assertEqual(row["state"], "failed", row)
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_rebuild_writes_moderation_credentials_for_only_the_replacement_generation(self):
saved = self.create_moderation_bootstrap(direct=True)
saved["moderation"] = {**self.moderation_configuration(2), "token": "ec_moderation_" + "n" * 43}
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2, "safetyOverride": True}, "rebuild"))
self.assertEqual(row["state"], "completed", row)
app = self.agent.instance_dir(INSTANCE, 2) / "app"
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(saved["moderation"], 2))
self.assertNotIn(saved["moderation"]["token"], row["result"])
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]