For Saturday's Ubuntu host + Linux VM: 'corrosion-host-agent install' writes a
systemd unit (Type=simple — the agent already handles SIGTERM cleanly),
daemon-reloads, and enables+starts the service; 'uninstall' reverses it.
- new service.rs: pure unit_file_contents() generator (unit-tested) + Linux
install/uninstall via systemctl; non-Linux returns a clear 'Linux only' error
(Windows SCM is the follow-up).
- ExecStart honors the resolved --config path (default or explicit).
- Runs as root: the agent supervises game processes + their files, needs broad
filesystem access.
cargo check + service unit test green. Tag agent-v2.0.0-alpha.11 -> CI signs ->
CDN /host-agent/alpha/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The legacy Go agent was never deployed, so the entire backend command surface
published to a dead cmd.server/cmd.wipe/files.cmd void. Route it all to the
Rust agent's instance-scoped subjects.
Agent (corrosion-host-agent, alpha.10):
- New src/wipe.rs + 'wipe' func on {instance}.cmd: stop -> delete game files by
type (map/blueprint/full, with optional backup) -> restart. Jailed to the
instance root, symlink-safe (lstat, no cross-boundary follow — Lesson 26).
8 tests incl. jail-escape + symlink-skip proofs. Agent suite 64 tests green.
Backend (NestJS):
- InstancesService is now @Global with license-scoped convenience wrappers
(lifecycleForLicense/rconForLicense/writeFileForLicense/readFileForLicense/
deleteFileForLicense/wipeForLicense) + resolveDefaultInstance (license ->
primary instance).
- Routed to the agent: servers start/stop/restart/command; players kick/banid/
unban via RCON; schedules restart/announce/command/plugin-reload; wipes ->
wipeForLicense (real wipe now); plugins reload/unload/upload via rcon+file
ops; all 9 plugin-config module applies -> writeFileForLicense + oxide.reload
rcon, imports -> readFileForLicense (server:// prefix stripped).
- Honestly gated (need agent funcs not yet built): server deploy-from-panel,
Oxide install, one-click uMod install -> 503 coming-soon instead of dead
publishes.
Backend tsc green; agent cargo test green (64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a Supervisor trait (async-trait) so the agent manages games with
different models behind one wire contract. ProcessSupervisor (spawned process:
rust/conan/soulmask) and the new DockerComposeSupervisor (dune) both impl it;
Agent.supervisors is now HashMap<String, Arc<dyn Supervisor>> and instancecmd
dispatch is game-agnostic — start/stop/restart/status identical across games,
selected by a per-game factory in main. InstanceState moved to the shared
supervisor module.
DockerComposeSupervisor drives docker-compose up-d / stop / restart against
the instance's compose project, with -f/-p/single-service support and a
configurable compose binary. New [instance.docker_compose] config block.
First cut = lifecycle + cached state; container crash-detection + restart
adoption deferred to Phase 3b (reconcilable with a compose ps probe).
Trait choice (dyn over enum) per Commander: scales to future planes (kubectl,
AMP/podman, SSH) as new struct+impl, no central match.
56 tests green (6 new docker-compose mock-binary tests + 5 refactored process
tests), zero warnings. Live verification pending a real Dune stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'Sign artifacts' step failed on alpha.7 with 'Error while loading the
secret key file' (exit 2): minisign downloaded and ran, but the reconstructed
key file was unparseable. A minisign secret key is two lines (comment + base64
blob); Gitea/act_runner secret storage mangles the embedded newline, collapsing
it to one line. Decode the secret as base64 (single-line, mangling-proof) with
auto-detect fallback to a raw two-line key. Fails loudly with the fix command
if the secret is neither form.
Requires re-storing MINISIGN_SECRET_KEY as: base64 < secret.key | tr -d '\n'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha.6 signing failed: 'E: Unable to locate package minisign' —
minisign isn't packaged for node:20-bullseye. Download the official
static linux binary instead. Forward to alpha.7 (alpha.6 published
nothing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent only ever runs a binary whose minisign signature verifies against
the EMBEDDED public key. NATS host.cmd func 'update' {url}: download
binary + .minisig from the CDN -> verify against embedded pubkey ->
atomic swap (.old rollback) -> relaunch. URL allowlist (https + cdn.
corrosionmgmt.com only, rejects userinfo-bypass), 100MiB cap. Closes the
supply-chain hole: even a malicious CDN upload can't run unsigned.
CI: build-host-agent.yml signs every artifact with MINISIGN_SECRET_KEY
(Gitea secret) and publishes .minisig alongside; the step FAILS the
build if the secret is absent (refuses to ship unsigned). Bumped to
alpha.6.
6 deterministic tests (accept valid / reject tampered+garbage+empty sig,
URL allowlist incl userinfo-bypass, atomic swap+rollback). Fixtures
signed with the real release key so tests need no key at runtime. Full
suite 50/50 green; musl + native build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two HIGH findings from automated review on the generator, both fixed:
1. Cross-tenant inbox access: per-license users were granted _INBOX.>,
letting license A subscribe to license B's request-reply responses.
Now scoped to corrosion.{license}.> ONLY; replies must ride the
license namespace (corrosion.{license}.reply.<id>) — documented in
PROTOCOL.md. Agent unchanged (responds to msg.reply); constraint is
on the requester (internal user has full >).
2. Default-open auth bypass: generator defaulted to stage=open with a
full-access anonymous user — a stale regen left the broker wide open.
Now defaults to enforce (secure by default); the explicit 'open'
migration stage maps anonymous to a harmless corrosion.unclaimed.>
namespace, never real tenant subjects. Committed bootstrap default
hardened the same way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the open broker (anonymous publish to any tenant's corrosion.*).
Per-license isolation via NATS user/password + subject permissions:
each license -> user=license_id, password=HMAC-SHA256(license_id,
NATS_TOKEN_SECRET), scoped to corrosion.{license_id}.> + _INBOX. Backend
uses a privileged internal user.
- Agent (alpha.5): nats_user/nats_password config + env, user_and_password
auth; falls back to token/anonymous (transition-safe)
- Backend: connects with NATS_INTERNAL_USER/PASSWORD when set, else anon
- scripts/generate-nats-auth.mjs: regenerates nats-auth.conf from the
licenses table; NATS_AUTH_STAGE=open keeps a no_auth_user fallback
(verify creds first), =enforce rejects anonymous
- committed nats-auth.conf is the SAFE OPEN default (no secrets); the
host copy carries real users and is not committed
- compose: NATS_INTERNAL_USER/PASSWORD/NATS_TOKEN_SECRET, mount nats-auth.conf
Entirely non-breaking until secrets+config deployed; staged cutover next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Automated security review (HIGH) caught a jail-escape my own review
missed: copy_recursive used fs::metadata (follows symlinks). A symlink
inside the jail pointing to e.g. /etc, then a 'copy' of its parent dir,
would dereference it and pull external content INTO the jail where it
could be read — a read-escape exfiltration. jail() validates only the
top-level src/dest; the recursive walk reintroduced the escape.
Fix: copy_recursive uses symlink_metadata and refuses any symlink
('symlinks are not followed across the jail boundary'). list() likewise
switched to symlink_metadata so it reports the link, never the
dereferenced target's size/type (info leak). Two regression tests added:
copy-symlink-exfil (asserts no external content lands inside) and
list-no-deref. 44/44 tests green. Rolled forward to alpha.4 (vulnerable
alpha.3 superseded).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
steam_update func runs SteamCMD per game (rust/conan/soulmask app-ids;
dune rejected), streaming stdout to {instance}.steam_status. Jailed
file manager on {instance}.files.cmd: list/read/write/delete/rename/
mkdir/mkfile/move/copy, all confined to instance root via two-stage
lexical-normalize + canonicalize (defeats ../ traversal AND symlink
escape — incl chained symlinks). Replaces the Go agent's UNJAILED
legacy files API (retired, not ported). 5MiB read cap.
42/42 tests green: 24 filemanager incl 7 jail-escape attempts
(dotdot, deep dotdot, absolute, symlink-inside, direct symlink,
chained symlink), 5 steamcmd app-id (cfg-gated win/linux soulmask).
Jail logic reviewed line-by-line: Path::starts_with is component-wise
(no sibling-prefix bypass), non-existent suffix components can't be
symlinks, leading .. normalizes to / and fails the prefix check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rcon func on the instance command channel: WebSocket JSON WebRCON with
Identifier correlation (skips chat/log noise frames) and full Valve
Source RCON over TCP (auth, exec, multi-packet reassembly via empty
probe, 1MiB cap). Protocol inferred from game, explicit kind override
in [instance.rcon]. Always 127.0.0.1 — agent is co-located.
Hardening from review: WebRCON password never interpolated into error
contexts/logs (redacted URL); probe-tolerant termination — a quiet
period after received data ends the response for servers that don't
echo the probe (Soulmask conformance unverified), so data is never
discarded on probe timeout.
13/13 tests green incl. mock Source-RCON server (auth/multi-packet/
errors) and mock WebRCON server (noise-frame skipping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-instance ProcessSupervisor: tokio child spawn with proper arg list
(fixes Go's naive space-splitting), graceful SIGTERM with 30s budget
then force kill, monitor task classifying ordered-stop vs crash (exit
code captured), watch-channel state observable everywhere. Instance cmd
channel live on corrosion.{license}.{instance}.cmd (start/stop/restart/
status) with state events pushed on {instance}.status (keep-latest
semantics, documented). Heartbeats now carry live process state +
uptime per instance. Crate restructured lib+bin for integration tests.
Verified: 5 integration tests with real OS processes (lifecycle, crash
exit-code, restart recovery, unmanaged rejection, clean spawn failure)
+ live-NATS contract test (request-reply roundtrips, double-start
rejection, push events, heartbeat state) — all green.
Known limitation (documented): no PID adoption yet — agent restart
orphans a running game process to 'stopped' until panel restart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Asgard runner executes jobs in bare node:20-bullseye (no Rust, no sudo)
- install rustup + musl/mingw cross toolchains per-run, same pattern as
setup-go in the Go pipeline. agent-v2.0.0-alpha.1 predates this fix;
forward-only doctrine: version rolls to alpha.2 rather than re-pushing
the tag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New corrosion-host-agent/ crate (Go companion-agent stays as behavior
reference until parity). Wire protocol v2 per COA-B: instance-scoped
subjects corrosion.{license}.{instance}.* + host-level .host.* — spec
in PROTOCOL.md, designed for the license->host->instance fleet model.
- Multi-instance TOML config in the foundation, not retrofitted
- NATS layer on the Vigilance production profile (infinite reconnect,
capped backoff, 30s ping, 8192-msg offline buffer)
- Heartbeat with real sysinfo telemetry — Go agent shipped hardcoded
disk/cpu placeholders; this is the panel's first true Resources data
- Connectivity prober (outbound TCP, periodic + on-demand)
- Host cmd channel (ping/probe/sysinfo), going-offline beacon,
CancellationToken shutdown
- Live-fire verified against production NATS; artifacts: 3.7MB static
linux-musl, 3.8MB windows .exe (static CRT)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>