Commit Graph

33 Commits

Author SHA1 Message Date
af0e6be9f5 fix(egg): the launcher drops Carbon's preloader for itself, not just the sidecar
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m0s
Carbon's entrypoint puts LD_PRELOAD=libdoorstop.so in front of the whole
startup string, and with Carbon's DOORSTOP_* environment that preloader
breaks the launcher shell's own command substitution: the sidecar's
--print-config output went straight to the console and the capture came
back empty. The first Carbon server built from the egg printed its config
JSON, token included, unframed, then "server id 'main', sidecar URL
http://192.168.0.12: (listening on )" and never the once-only banner.
`env -u LD_PRELOAD` on the sidecar alone was not enough: the shell doing
the capturing had the preload too.

The launcher now re-execs itself once without LD_PRELOAD, keeps it aside,
and gives it back to the game in run_game, the only process that needs it.

Reproduced and fixed under Carbon's real environment (environment.sh +
libdoorstop.so): the released launcher dumps the JSON; this one prints the
banner and "server id 'egg-carbon', sidecar URL http://192.168.0.12:21019",
and the game still gets LD_PRELOAD and DOORSTOP_ENABLED. The four
no-preload cases in the game image are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 04:28:05 -05:00
6c46d9388a Merge pull request 'fix(egg): name the egg runicgateway-rust-autowipe' (#16) from fix/egg-name into main
All checks were successful
Release sidecar / release (push) Successful in 5m19s
Reviewed-on: #16
v0.1.2
2026-09-26 08:45:32 +00:00
08ad7b93ce fix(egg): name the egg runicgateway-rust-autowipe
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m30s
The org lead's name for it in the panel. Pterodactyl takes an egg's name
from the imported JSON, so it is set here rather than renamed by hand
after every import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:31:56 -05:00
bc1919ce38 Merge pull request 'fix(sidecar): refuse a plugin that names another server (D155)' (#15) from fix/refuse-foreign-plugin into main
All checks were successful
Release sidecar / release (push) Successful in 5m12s
Reviewed-on: #15
v0.1.1
2026-09-26 06:27:15 +00:00
3f5ed059b8 fix(sidecar): refuse a plugin that names another server (D155)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
`[game].server_id` was a cross-check that WARNED and kept the plugin's id.
The phase 18 walk showed what that costs: a second server's plugin,
parked in this listener's backlog by a plugin bug (Rust-Plugins, D154),
was accepted the moment the first server's plugin reloaded, and the
website showed server "alpha" with beta's hostname and wipe.

Now, with `server_id` set, the connection is closed on the first frame
that names another server, BEFORE that frame reaches the store or the
feed, and both ids are logged at ERROR. The command channel is installed
only once a frame has named this server, so no website command (a grant,
a world write) can reach a plugin about to be refused, and /health reports
the plugin connected only from then. Blank `server_id`: nothing checked,
as before.

The egg's launcher now hands the sidecar the plugin config's ServerId once
that file exists. The plugin reads RUSTLINK_SERVER_ID only at its first
config write (D150); without this, a variable edited after the first boot
would be refused instead of changing nothing, as INSTALL.md promises.

Walked: a plugin aimed at another server's sidecar is refused with an
ERROR naming both ids, and the site keeps the right server's identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:19:50 -05:00
1f3dfb55b2 Merge pull request 'chore(release): cut edge over to main — protocols 3–12, the egg, and the first release (rust phase 18, D145)' (#14) from edge into main
All checks were successful
Release sidecar / release (push) Successful in 8m21s
Reviewed-on: #14
v0.1.0
2026-09-26 05:45:07 +00:00
d8b638773d Merge pull request 'feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18)' (#13) from feat/phase-18-release into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m34s
Reviewed-on: #13
2026-09-26 05:34:13 +00:00
b3b66b1cc2 feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m53s
Module-rust phase 18, step 4 of docs/modules/rust/PLAN.md §34.2.7.

The Windows service (D149, §34.2.5): src/windows.rs, ported from link's fix
for error 1053. The same exe tries the SCM handshake and falls through to a
console run on 1063; it reports Running only once the listener and store are
up, and logs to a daily file beside its config. One binary serves every
RunicGatewayRust-<id> instance, because the SCM ignores the dispatcher's name
for an own-process service.

An empty environment variable now counts as unset. A Pterodactyl egg exports
every variable it declares, so a blank RUSTLINK_WEB_TOKEN arrived as "" and
overrode the saved token, and a new one was generated and persisted on every
boot. That breaks D152, which this change makes true.

The egg (R20, R22, D151, D152, §34.2.6), in egg/:
- install.sh is egg 18's script with two changes. A wipe guard moves
  rust-link/ to /tmp around `rm -rf ${REMOVE_FILES}`. The bridge block then
  fetches a schema-2 Rust bundle (pinnable by RUNICGATEWAY_BUNDLE), checks
  every asset's sha256 and the plugin's protocol before placing anything, and
  places the plugin by FRAMEWORK. Vanilla installs nothing and does not fail.
- with-sidecar.sh is the launcher. It unsets blank variables, builds the web
  bind from RUSTLINK_WEB_PORT, and runs --print-config so that a newly
  generated token is printed once. It prints the URL and server id for the
  admin page, then execs the game. It no longer uses `set -e`: nothing the
  bridge gets wrong may keep the game from booting.
- The startup's launcher prefix is conditional, so a server with no bridge
  boots exactly as egg 18 does.
- build.sh assembles egg-rust-runicgateway.json. PR Checks runs it.

The release (D145, §34.2.1) reuses servuo-plugins' engine. It publishes the
static musl Linux binary, the Windows exe, the launcher, the egg and
SHA256SUMS, and dispatches the installer's bundle.yml. PR Checks gains a
clippy run for the Windows target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:14:57 -05:00
6aa6cdcf97 Merge pull request 'feat(sidecar): protocol 12 — POST /titles (phase 17)' (#12) from feat/phase-17-integrations into edge
Reviewed-on: #12
2026-09-25 23:31:25 +00:00
46ba35d89a feat(sidecar): protocol 12 — POST /titles (phase 17)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after -35s
One more thin forward: the chat titles each player has earned, as a whole
set the plugin swaps in and BetterChat reads on the chat path. perm.sync's
BetterChat styles and chat.say's delivery and format pass through untouched
like the rest of their bodies. PROTOCOL_VERSION moves to 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:05 -05:00
73b2523aa6 Merge pull request 'feat(sidecar): protocol 11 — four map forwards (phase 14)' (#11) from feat/phase-14-map into edge
Reviewed-on: #11
2026-09-25 12:00:35 +00:00
9e2a83d7c9 feat(sidecar): protocol 11 — four map forwards (phase 14)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
GET /map, GET /map/chunk, POST /map/render and GET /map/live, stamped and
forwarded like the reward routes (PLAN.md §30.2). Nothing is stored: the
picture passes through in 512 KiB slices and positions are never filed
(D111). A test asserts a base64 slice plus its envelope fits the game
link's 1 MiB line cap on the way back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 00:11:19 -05:00
06c3ed79b4 Merge pull request 'feat(sidecar): protocol 10 — five reward forwards (phase 13b)' (#10) from feat/phase-13b-rewards into edge
Reviewed-on: #10
2026-09-24 13:01:37 +00:00
c80994ce87 feat(sidecar): protocol 10 — five reward forwards (phase 13b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m13s
POST /tally/open, GET /tally/snapshot, POST /tally/close, GET /kits and
POST /chat: who took part in a run, the kits a reward can name, and one
line in the server's chat (the module's PLAN.md §29). Thin forwards like
protocol 9's; the tally, the kit catalogue and the chat memory all live
in the plugin. perm.sync's new `credits` field passes through untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 06:37:38 -05:00
86205d6140 Merge pull request 'feat(sidecar): protocol 9 — five world forwards (phase 13a)' (#9) from feat/phase-13a-world into edge
Reviewed-on: #9
2026-09-24 10:09:13 +00:00
a1a6177921 feat(sidecar): protocol 9 — five world forwards (phase 13a)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m58s
GET /world/monuments, GET /world/owned, POST /world/zone, POST /world/place
and POST /world/revert, each a correlated round trip with cmd and reqId
stamped over the caller's. The allowlist, bounds, monument vocabulary and
the registry of what each run owns live in the plugin (the module's PLAN.md
§28); this process moves lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 00:42:54 -05:00
788bed89a0 Merge pull request 'feat(sidecar): protocol 8 — three lease forwards (phase 12)' (#8) from feat/phase-12-leases into edge
Reviewed-on: #8
2026-09-24 04:03:12 +00:00
d688dfca66 feat(sidecar): protocol 8 — three lease forwards
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m42s
GET /lease, POST /lease, POST /lease/release, each a correlated round trip with cmd and reqId stamped over the caller's. lease.expired is an ordinary event. The sidecar learns nothing about keys, bounds or deadlines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 21:14:03 -05:00
698fbb386d Merge pull request 'feat(sidecar): protocol 7 — the raid frame, and again no new code' (#7) from feat/phase-10-engagement into edge
Reviewed-on: #7
2026-09-23 18:43:43 +00:00
cb0913388d feat(sidecar): protocol 7 — the raid frame, and again no new code
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m7s
entity.destroyed gains the cupboard's authorised list and covers doors,
walls and the cupboard. The sidecar stores and serves events as they
arrive, so only the number moves. The bump is what stops a website that
alerts on `authorized` from pairing with a protocol-6 plugin that never
sends it, which would read every raid as a base with no cupboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:52:56 -05:00
437b392861 Merge pull request 'feat(sidecar): protocol 6 — first-party clans, and no new code' (#6) from feat/phase-9-clans into edge
Reviewed-on: #6
2026-09-23 10:33:37 +00:00
4adc9bccf2 feat(sidecar): protocol 6 — first-party clans, and no new code
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m24s
Protocol 6 adds one board (`clans`) and five clan events. The sidecar
files frames by `type`, so none of them needs an arm here. That is
protocol 2's promise kept. The bump exists for the other declaration
sites: a website that reads clans must not pair with a plugin that
never sends them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:14:18 -05:00
6ad49ea9fe Merge pull request 'feat(sidecar): protocol 5 — three routes, and the one timeout worth explaining' (#5) from feat/phase-7b-config into edge
Reviewed-on: #5
2026-09-22 15:02:00 +00:00
9532b7b26c feat(sidecar): protocol 5 — three routes, and the one timeout worth explaining
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
`GET /config/files`, `GET /config/file` and `POST /config/write` (R18). The
sidecar keeps the property protocol 4 relied on: it defines no schema for any of
it, stores none of it, and judges no path — only the process holding the
configuration directory can decide whether a path resolves inside it, and a
guard here would be a weaker second opinion with no way to check itself.

Nothing reaches the store or the feed. A config this sidecar cached would be an
edit an operator made over SSH that the website then silently overwrote, so all
three routes fail when the game is down, like `/status`.

The one thing added beyond forwarding is a better 504 on the write: the plugin
writes a whole set or restores a whole set and never half of either, so the body
says to re-read rather than guess, and names the reload window that is probably
still running. `CONFIG_RELOAD_WINDOW` mirrors the plugin's, and a test asserts
that two of them plus slack fit inside `REPLY_TIMEOUT` — a rollback that reports
after its caller has gone is worse than no rollback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 08:54:08 -05:00
c005f5e12f Merge pull request 'feat(sidecar): protocol 4 — two routes, and no opinion about either' (#4) from feat/phase-7-permissions into edge
Reviewed-on: #4
2026-09-22 06:53:34 +00:00
8f5440089c feat(sidecar): protocol 4 — two routes, and no opinion about either
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m38s
`GET /permissions/catalogue` and `POST /permissions/sync` (R2). The first pair
that exists so the website can WRITE to the game, and the smallest change in this
repository that a protocol bump has ever needed.

That is the dumb-forwarder property paying for itself a second time: protocol 4
adds the largest command on the bridge and touches neither the store nor the feed.
The sidecar does not know what a group is, which names are managed, or what the
plugin will do with any of it. It puts an envelope on an object and forwards it.

**The envelope is this side's.** `cmd` and `reqId` are inserted AFTER the caller's
object is taken, so they overwrite anything a caller put there — no request can
arrive claiming to be a different command, or aimed at a correlation id somebody
else is waiting on.

**A command larger than the game link's line cap is refused here**, with the
limit in the body. Forwarded, it would be discarded silently by both ends
(§3.1 — an over-long line is dropped, not buffered) and present to the caller as
a `504`, which sends an operator to look at a game server that is working
perfectly.

Two tests, and both assert a refusal rather than a happy path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:27:54 -05:00
47ffc9a78c Merge pull request 'feat(sidecar): protocol 3 — the first route on this bridge that is not a GET' (#3) from feat/phase-6-identity into edge
Reviewed-on: #3
2026-09-21 22:25:27 +00:00
fd6efd9a2c feat(sidecar): protocol 3 — the first route on this bridge that is not a GET
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m30s
`POST /link/confirm` forwards a one-time link code to the plugin and hands back
what it says. Everything before it was the website reading what the game had
already told us; this is the website asking the game a question only the game can
answer.

**It is still a forwarder and holds no authority of its own.** It does not mint
codes, does not store them, does not know what a website user is, and cannot tell
a good code from a bad one. Putting the code table here would give the sidecar a
credential and an opinion, and D2 and the bridge principles say it has neither.

**A refused code is a 200.** `link.ok` and `link.error` are both answers, and the
website has to tell "that code is wrong" from "the game never replied" to say the
right thing to a player. The two transport failures keep the codes `respond`
already gives them: 503 when the game is down, 504 when it is up and silent.

`usable_code` is split out and tested because its two rejections are easy to get
subtly wrong. It trims BEFORE it measures: a player pasting a code out of game
chat brings whitespace with it, a field of nothing but spaces is empty rather
than four characters long, and the length bound belongs on the trimmed value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 07:36:45 -05:00
3aabd2befe Merge pull request 'feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history' (#2) from feat/phase-3-read-path into main
Reviewed-on: #2
2026-09-16 16:37:26 +00:00
06fa5d7330 feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s
The sidecar now files a frame by its `type` and never by its `kind`. That is the
dumb-forwarder property made structural: `event` is appended to history,
`snapshot` replaces the board of its kind, `reply` is routed by `reqId`,
`control` is broadcast and kept nowhere. Ten new event kinds are no change here
at all, which is the whole point when the thing that grows fastest is the
catalogue.

A frame whose `type` this build does not know is dropped and counted, never
guessed at. Defaulting an absent one to `event` would file a BOARD as history —
the presence board appended a few thousand times, which nothing reports. The
count is on `/health` as `untyped_frames`, because the failure it diagnoses (a
plugin and a sidecar on different protocol versions, which the game link has no
handshake to catch) otherwise presents as a website showing nothing while the
game is plainly up. It caught exactly that within three seconds of first running,
against a protocol 1 plugin still live on a retired rig.

`boards` generalises protocol 1's single `server_state` row, and a database made
by protocol 1 is migrated in place: the two indexed columns are added by a
guarded `ALTER`, and the old board is carried across. Without that carry-over an
upgraded sidecar answers `204` until the game next connects, and the website
reads that as "never heard from" — losing a server it has rendered for weeks at
the exact moment somebody upgraded the bridge.

`GET /feed` is the ingest cursor: oldest first, strictly after an id, with
`lastId` and `more`. It is a separate route rather than a flag on `/events`
because one route with two orderings serves the other one to every caller that
forgets the parameter — and for the ingesting caller that means advancing its
cursor past rows it never read. Omitting `since` asks where the END is; `since=0`
is the other question entirely, and the two must not be separated by whether
somebody typed a parameter.

`[store].retain_days` (default 14) prunes events hourly. Boards are never pruned:
history grows and the present does not, and a pruned board is a server that has
never connected.

The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test
gates phases 1 and 3 have both been running by hand — a guard nothing invokes is
a guard whose state nobody knows.

44 tests pass, clippy clean at `-D warnings`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 08:18:43 -05:00
c526c55c36 Merge pull request 'feat(sidecar): protocol 1 — the transport' (#1) from feat/phase-1-transport into main
Reviewed-on: #1
2026-09-16 01:31:22 +00:00
e2a58f3455 feat(sidecar): protocol 1 — the transport
The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin
dials into, and serves the website a WebSocket feed plus store-backed reads.

Protocol 1 is deliberately three frames — server.hello, ping/pong, and one
correlated server.status — because phase 1's job is to get every seam working at
once with almost nothing in them.

What is load-bearing rather than incidental:

* The plugin is the TCP client and this process owns the listener, so a Rust
  server opens no extra port. Loopback is the trust boundary on that link and
  there is no token on it; the website-facing surface is the opposite, with auth
  always on and a token generated and persisted on first start.
* Inbound lines are capped at 1 MiB from the start rather than after the first
  large frame arrives. An over-long line is discarded and the connection stays
  up: one malformed frame is not a reason to drop a link live events flow over.
* Store-backed reads answer while the game is off, which is what lets a website
  render a server list during a wipe. /status is the one route that fails when
  the game is down, and /server answers 204 rather than a null when the game has
  never connected -- those are different answers and a client that cannot tell
  them apart renders a server that does not exist.
* The two RPC failures get distinct codes. 503 means the game is down; 504 means
  it is up and did not answer. Different fixes.
* rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core
  classifies a budget overrun as retryable unconditionally, so an action whose
  budgetMs does not exceed it can never report retry:false.

One defect found while building, which no unit test would have caught: a
four-connection SQLite pool over :memory: hands out four separate empty
databases, because an in-memory database is per connection. It presents as
'no such table' from a random subset of queries. The pool is now capped at one
connection for an in-memory path, which is the only coherent reading of
:memory: and is what makes it usable at all.

Exercised end to end against a live Rust server: a server.hello travelled game
-> sidecar -> module -> the public website API, and killing this process left
the game untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:52:55 -05:00
d6a93506e8 chore: repository scaffolding
The org's standard furniture for a new repo: licence, code of conduct, security
policy, contributing guide, issue and pull-request templates, and the ignore
rules. No sidecar code yet — that arrives as the first pull request, so this
branch exists to open one against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:52:25 -05:00