From 47692e88325be9489ea0415099fb65ea2fd41c25 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 17 Sep 2026 07:33:45 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20protocol=203=20=E2=80=94=20/lin?= =?UTF-8?q?k,=20/unlink,=20and=20the=20first=20command=20the=20site=20send?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all. **`/link` is chat, not console, and the reply is private** (D21). Both frameworks consume a `/` command rather than broadcasting it and `SendReply` addresses one player, so neither the request nor the code reaches anybody else — a code read off a stream is a code somebody else can spend. **Pending codes live in memory and nowhere else** (D22, matching module-uo): six characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer because an unconfirmed code is never looked up. A plugin reload drops them — phase 7b's config editor will do that routinely — and the cost is typing `/link` again, which is cheaper than a credential living in a second process. **The code is deliberately NOT in `account.link.requested`.** That event exists so an operator can see linking being used; the code travels through the player, which is what makes typing it proof they are the one who asked. `/unlink` emits rather than deletes, because there is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe: a player who linked the wrong account walks to a computer they are already sitting at. `link.confirm` consumes a code on the FIRST lookup whether or not it turns out to be expired, so a code cannot be probed twice. `unknown` and `expired` stay separate in the reply — an operator wants to know whether codes are being guessed or going stale — and the website says the same sentence for both. `checkPlugin.js` gains a check for the new surface, because a chat command binds by reflection exactly like a hook and fails the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- overlay.toml | 2 +- overlay/oxide/plugins/RunicGateway.cs | 353 +++++++++++++++++++++++++- scripts/checkPlugin.js | 67 ++++- scripts/checkPlugin.test.js | 86 ++++++- 4 files changed, 502 insertions(+), 6 deletions(-) diff --git a/overlay.toml b/overlay.toml index d3a3ca7..97adc01 100644 --- a/overlay.toml +++ b/overlay.toml @@ -27,7 +27,7 @@ # PROTOCOL_VERSION and the module bumps its own constant. # # Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8). -protocol = 2 +protocol = 3 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index dd1fb84..520dcc2 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,7 +56,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 2; + private const int ProtocolVersion = 3; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -243,6 +243,10 @@ namespace Oxide.Plugins _wipeId = ResolveWipeId(); _running = true; + // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is + // never looked up, so nothing else would ever remove one (protocol 3). + timer.Every(60f, PurgeExpiredCodes); + _link = new Thread(LinkLoop) { Name = "RunicGateway Link", @@ -743,6 +747,13 @@ namespace Oxide.Plugins EmitFrame(status); break; + case "link.confirm": + // The first command the WEBSITE originates (protocol 3). Everything before it + // was the game talking, or the sidecar asking the game to say something it + // already knew. + HandleLinkConfirm(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1645,6 +1656,346 @@ namespace Oxide.Plugins .TotalMilliseconds; } + // ---- account linking ---- + + /// + /// The alphabet a link code is drawn from. + /// + /// + /// No O, 0, I or 1. A player reads this code off their screen and types it into + /// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four + /// glyphs are the ones that get transcribed wrong, and a code that fails for a reason + /// the player cannot see is worse than a longer one. + /// + /// + private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private const int CodeLength = 6; + + /// + /// How long a code is good for. Short on purpose: R1 made this an authorization path, + /// not a convenience — the site becomes the author of who may do what in game, so a + /// code that lingers is a privilege waiting to be picked up. + /// + private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); + + /// How long a player must wait between /link requests. + private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); + + /// + /// An unconfirmed code, and the Steam identity it will bind. + /// + /// + /// These live in memory and nowhere else, which is the same call the UO bridge + /// made and it is the right one for the same reason: the secret exists only on the + /// server that minted it, for five minutes, and nothing writes it to disk. A plugin + /// reload drops every pending code — and phase 7b's config editor reloads plugins + /// routinely — but the cost of that is a player typing /link again, which is + /// cheaper than a credential in a second process. + /// + /// + private sealed class PendingLink + { + public string SteamId; + public string Name; + public DateTime Expires; + } + + /// code -> pending link. Main thread only, like every world read here. + private readonly Dictionary _codes = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// steam id -> when they last asked, so /link cannot be spammed. + private readonly Dictionary _lastLinkRequest = + new Dictionary(StringComparer.Ordinal); + + private readonly System.Random _codeRandom = new System.Random(); + + /// + /// /link — mint a one-time code for the caller and tell the sidecar about it. + /// + /// + /// Chat, not console, and the reply is private. Both frameworks consume a + /// / command rather than broadcasting it, and SendReply addresses one + /// player — so neither the request nor the code reaches anybody else's chat. That is + /// load-bearing rather than polish: a code read off a stream is a code somebody else + /// can spend. + /// + /// + /// + /// The plugin does not record who is linked. The website is the author of + /// record for that (R2), and a second copy here would be a second thing to keep in + /// step across a wipe. So there is no "you are already linked" check on this path — + /// the site refuses a Steam id it already holds, where it can say which account holds + /// it. + /// + /// + [ChatCommand("link")] + private void CmdLinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DateTime last; + if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown) + { + SendReply(player, "Please wait a moment before asking for another link code."); + return; + } + + // One outstanding code per player: drop any earlier one so only the newest works. + // Without this a player who ran /link three times leaves three live codes behind. + DropCodesFor(steamId); + + string code = MintCode(); + _codes[code] = new PendingLink + { + SteamId = steamId, + Name = player.displayName, + Expires = DateTime.UtcNow + CodeTtl + }; + _lastLinkRequest[steamId] = DateTime.UtcNow; + + Dictionary frame = Frame("account.link.requested", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["ttlSec"] = (long)CodeTtl.TotalSeconds; + + // **The code is NOT in this frame, and that is the point.** The event exists so an + // operator can see that linking is being used and so the site can rate-limit a player + // who is fishing; it is not how the code travels. The code travels through the player, + // which is what makes typing it proof that they are the one who asked. + EmitFrame(frame); + + SendReply(player, "Your link code is " + code + "."); + SendReply( + player, + "Enter it on the website within " + (int)CodeTtl.TotalMinutes + + " minutes to link this Steam account. It works once."); + } + + /// + /// /unlink — ask the website to sever this Steam id's link. + /// + /// + /// It emits rather than deletes, because the plugin holds no link to delete. The + /// site owns the record and applies this like any other event off the feed. + /// + /// + /// + /// It exists because the site refuses to move a Steam id that another account + /// already holds. Without a way out, a player who linked the wrong account while logged + /// in as it would need staff; with this they walk to a computer they are already sitting + /// at. The authority is the Steam id itself — whoever is connected as it is who it is. + /// + /// + [ChatCommand("unlink")] + private void CmdUnlinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DropCodesFor(steamId); + + Dictionary frame = Frame("account.unlinked", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["origin"] = "in-game"; + EmitFrame(frame); + + SendReply( + player, + "Asked the website to unlink this Steam account. If it was linked, it is not now."); + } + + /// + /// Inbound link.confirm — the website has been given a code and wants to know + /// whose it is. + /// + /// + /// This is the first command the website originates in this bridge; everything + /// before protocol 3 was the game talking. The reply carries the Steam id and nothing + /// the caller did not already prove — there is no session here, no account creation, + /// and no write to the game. + /// + /// + /// + /// A code is consumed on the first lookup whether or not it turns out to be + /// expired, so a code cannot be probed twice. That is why the removal happens before + /// the expiry check rather than after it. + /// + /// + private void HandleLinkConfirm(Dictionary cmd, string reqId) + { + object codeObj; + string code = cmd.TryGetValue("code", out codeObj) && codeObj != null + ? codeObj.ToString().Trim() + : null; + + if (string.IsNullOrEmpty(code)) + { + EmitLinkError(reqId, "malformed"); + return; + } + + PendingLink pending; + if (!_codes.TryGetValue(code, out pending)) + { + EmitLinkError(reqId, "unknown"); + return; + } + + _codes.Remove(code); + + if (DateTime.UtcNow > pending.Expires) + { + EmitLinkError(reqId, "expired"); + return; + } + + Dictionary ok = Frame("link.ok", "reply"); + ok["steamId"] = pending.SteamId; + ok["name"] = pending.Name; + if (reqId != null) + ok["reqId"] = reqId; + + EmitFrame(ok); + } + + /// + /// A refusal, with a reason the website can turn into a sentence. + /// + /// + /// unknown and expired are deliberately separate here and + /// deliberately the same to the player: an operator reading a log wants to know + /// whether codes are being guessed or merely going stale, and a stranger typing codes + /// must not learn which of the two they hit. + /// + /// + private void EmitLinkError(string reqId, string reason) + { + Dictionary frame = Frame("link.error", "reply"); + frame["reason"] = reason; + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Drops every outstanding code belonging to one Steam id. + private void DropCodesFor(string steamId) + { + List stale = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value != null && entry.Value.SteamId == steamId) + { + if (stale == null) + stale = new List(); + + stale.Add(entry.Key); + } + } + + if (stale == null) + return; + + foreach (string key in stale) + _codes.Remove(key); + } + + /// + /// Drops expired codes and stale cooldowns. + /// + /// + /// Run on a timer rather than only on lookup, because an unconfirmed code is never + /// looked up — a player who runs /link and wanders off leaves an entry that + /// nothing else would ever remove. + /// + /// + private void PurgeExpiredCodes() + { + DateTime now = DateTime.UtcNow; + List dead = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value == null || now > entry.Value.Expires) + { + if (dead == null) + dead = new List(); + + dead.Add(entry.Key); + } + } + + if (dead != null) + { + foreach (string key in dead) + _codes.Remove(key); + } + + List cold = null; + + foreach (KeyValuePair entry in _lastLinkRequest) + { + if (now - entry.Value > RequestCooldown) + { + if (cold == null) + cold = new List(); + + cold.Add(entry.Key); + } + } + + if (cold == null) + return; + + foreach (string key in cold) + _lastLinkRequest.Remove(key); + } + + /// + /// A code from , retried until it does not collide. + /// + /// + /// System.Random rather than a cryptographic source, and the reason it is + /// defensible is the shape of the guess rather than the shape of the generator: a code + /// lives five minutes, is consumed on first lookup, and the website rate-limits the + /// confirm endpoint. An attacker who could predict the sequence would still have to + /// reach a code before the player does. + /// + /// + private string MintCode() + { + for (int attempt = 0; attempt < 16; attempt++) + { + var chars = new char[CodeLength]; + + for (int i = 0; i < CodeLength; i++) + chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)]; + + string candidate = new string(chars); + + if (!_codes.ContainsKey(candidate)) + return candidate; + } + + // Sixteen collisions in a row against a table this small is not bit-space exhaustion, + // it is something being wrong. A code that cannot collide is better than a loop that + // might not end. + return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); + } + // ---- diagnostics ---- /// diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 56b04eb..d7ac002 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -62,6 +62,12 @@ const ANSWERS_DELIBERATELY = Object.create(null) /** Anything shaped like this is a game hook, by both frameworks' own convention. */ const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ +/** + * The parameter list both frameworks bind a chat command on: the caller, the command word, and + * whatever followed it. Names are the author's; the types are not. + */ +const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/ + /** * Method declarations, as this file cares about them: the return type and the * name. Deliberately narrow — it matches the plugin's own single style @@ -82,6 +88,31 @@ function readExpectedHooks(source) { .map((m) => m[1]) } +/** + * Every `[ChatCommand("x")]` and the signature of the method under it. + * + * **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a + * method with the wrong parameter list is never called and neither framework says a word. The + * player types `/link`, nothing happens, and there is no log line to find. So the signature is + * checked here rather than discovered on a rig. + */ +function readChatCommands(source) { + const found = [] + const attribute = /\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g + + let match + while ((match = attribute.exec(source)) !== null) { + found.push({ + command: match[1], + returns: match[2].trim(), + name: match[3], + params: match[4].replace(/\s+/g, ' ').trim(), + }) + } + + return found +} + function readMethods(source) { const found = [] let m @@ -133,7 +164,39 @@ function check(source, toml) { } } - // 4. The two declaration sites this repository owns must agree. + // 4. Every chat command is bound by reflection and must have the signature the + // frameworks call. A wrong one is never invoked and says nothing. + const chat = readChatCommands(source) + + for (const cmd of chat) { + if (cmd.returns !== 'void') { + problems.push( + `/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` + + 'value is not read, and a non-void signature is the shape that silently binds nothing.' + ) + } + + if (!CHAT_SIGNATURE.test(cmd.params)) { + problems.push( + `/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` + + 'Both frameworks bind a chat command by reflection on that exact signature, so this one ' + + 'would never be called and neither would log it — the player types the command and ' + + 'nothing happens at all.' + ) + } + } + + // Two commands answering to one name is one of them never running, and which one is + // whichever the framework happened to register second. + const seen = new Set() + for (const cmd of chat) { + if (seen.has(cmd.command)) { + problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`) + } + seen.add(cmd.command) + } + + // 5. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) @@ -171,6 +234,6 @@ function main() { ) } -module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME } +module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index 1e9cbbf..ebb8eff 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -1,4 +1,4 @@ -// A check is worth what it catches, so this breaks it seven ways. +// A check is worth what it catches, so this breaks it thirteen ways. // // The case that matters most is the last one: `checkPlugin.js` finds hooks with a // deliberately narrow regex, and a regex that silently matches NOTHING passes @@ -16,7 +16,7 @@ const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') -const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin') +const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { @@ -128,3 +128,85 @@ test('the parser actually reads the real plugin, rather than quietly matching no const expected = readExpectedHooks(real) assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`) }) + +// ── Chat commands (protocol 3) ───────────────────────────────────────────── +// +// A chat command binds by reflection on an exact signature, and a wrong one is +// never invoked and never logged: the player types `/link` and nothing happens. +// That is the same failure mode the hook checks exist for, one surface along. + +/** A plugin whose only body is one chat command. */ +function withChat(attribute, signature, returns = 'void') { + return source({ + expected: [], + methods: ` [ChatCommand("${attribute}")] + private ${returns} CmdThing(${signature}) + { + }`, + }) +} + +const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' + +test('a correctly shaped chat command passes', () => { + assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), []) +}) + +test('a chat command taking the wrong player type is caught', () => { + // The plausible mistake: Covalence's `IPlayer` is what a Universal plugin + // takes, and this plugin is not one. It compiles and binds nothing. + const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) +}) + +test('a chat command missing the trailing args is caught', () => { + const problems = check(withChat('link', 'BasePlayer player, string command'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /nothing happens at all/) +}) + +test('a chat command that returns something is caught', () => { + const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /returns object, not void/) +}) + +test('two chat commands answering to one name is caught', () => { + const both = source({ + expected: [], + methods: ` [ChatCommand("link")] + private void CmdOne(${GOOD_SIGNATURE}) + { + } + + [ChatCommand("link")] + private void CmdTwo(${GOOD_SIGNATURE}) + { + }`, + }) + + const problems = check(both, toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /declared twice/) +}) + +test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => { + // The same trap the method parser has, and the reason this file's last test + // exists: a regex that matches NOTHING passes every case above. + const real = fs.readFileSync( + path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), + 'utf8' + ) + + const commands = readChatCommands(real) + const names = new Set(commands.map((c) => c.command)) + + assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`) + assert.ok(names.has('link'), '/link was not found by the chat-command parser') + assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') +})