Harden /ask helper, UDP replies and run_lua in multiplayer

- Pass the /ask question to claude on stdin so it can't be parsed as a CLI option.
- Block run_lua in multiplayer unless an admin enables the new
  "Allow Lua queries in multiplayer" map setting.
- Ignore UDP packets that don't come from the game's port, in both the
  MCP server and the ask helper.
- Build the screenshot path from our own name instead of the game's reply,
  and restrict screenshot names in the mod.
- Validate ask packets, cap question length and limit concurrent claude runs.
- Replace hard-coded home paths in the README and systemd unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
faabandClaude Opus 5.5 committed 2026-10-29 21:40:39 +01:00
1 parent 1a5cc7426e
commit c5b2f6d603
8 files changed
+79 -28

No files matched your search

+17 -10
View File
@@ -33,20 +33,21 @@ The game only listens on 127.0.0.1.
**3. Register the MCP server.** Requires [uv](https://docs.astral.sh/uv/). **3. Register the MCP server.** Requires [uv](https://docs.astral.sh/uv/).
Claude Code: Claude Code (run from this repo's root):
```bash ```bash
claude mcp add factorio --scope user -e FACTORIO_UDP_PORT=34200 -- uv run --directory /home/faab/Documents/Code/Factorio/claude-co-engineer/server claude-co-engineer-mcp claude mcp add factorio --scope user -e FACTORIO_UDP_PORT=34200 -- uv run --directory "$PWD/server" claude-co-engineer-mcp
``` ```
Claude Desktop — add to `claude_desktop_config.json` (Settings → Developer → Edit Config), then restart it: Claude Desktop — add to `claude_desktop_config.json` (Settings → Developer → Edit Config), with
`/path/to/claude-co-engineer` replaced by where you cloned this repo, then restart it:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"factorio": { "factorio": {
"command": "uv", "command": "uv",
"args": ["run", "--directory", "/home/faab/Documents/Code/Factorio/claude-co-engineer/server", "claude-co-engineer-mcp"], "args": ["run", "--directory", "/path/to/claude-co-engineer/server", "claude-co-engineer-mcp"],
"env": { "FACTORIO_UDP_PORT": "34200" } "env": { "FACTORIO_UDP_PORT": "34200" }
} }
} }
@@ -86,6 +87,10 @@ default, and are purely visual.
your world changes, turn off **Allow Lua queries** in Settings → Mod settings → Map. Every query is your world changes, turn off **Allow Lua queries** in Settings → Mod settings → Map. Every query is
logged to `factorio-current.log`. logged to `factorio-current.log`.
In multiplayer, `run_lua` is off unless an admin also turns on **Allow Lua queries in multiplayer**:
the Lua runs for everyone, and text other players put in the world (station names, labels) ends up
in what Claude reads, so it could be talked into changing things.
## Asking from inside the game ## Asking from inside the game
Type `/ask <question>` in the game chat (e.g. `/ask why is my green science slow?`). The answer Type `/ask <question>` in the game chat (e.g. `/ask why is my green science slow?`). The answer
@@ -100,16 +105,17 @@ the places it mentions get highlighted. Follow-up questions continue the same co
This needs: This needs:
- the `claude` CLI logged in with your subscription (`claude auth login`, once); - the `claude` CLI logged in with your subscription (`claude auth login`, once);
- the helper running. Either start it by hand: - the helper running. Either start it by hand from this repo's root:
```bash ```bash
~/Documents/Code/Factorio/claude-co-engineer/server/.venv/bin/claude-co-engineer-ask uv run --directory server claude-co-engineer-ask
``` ```
or install it as a systemd user service that starts at login: or install it as a systemd user service that starts at login (from this repo's root; this fills in
its path):
```bash ```bash
cp server/claude-co-engineer-ask.service ~/.config/systemd/user/ && systemctl --user daemon-reload && systemctl --user enable --now claude-co-engineer-ask uv sync --directory server && mkdir -p ~/.config/systemd/user && sed "s|/path/to/claude-co-engineer|$PWD|" server/claude-co-engineer-ask.service > ~/.config/systemd/user/claude-co-engineer-ask.service && systemctl --user daemon-reload && systemctl --user enable --now claude-co-engineer-ask
``` ```
Its log shows each question and answer: `journalctl --user -u claude-co-engineer-ask -f`. Its log shows each question and answer: `journalctl --user -u claude-co-engineer-ask -f`.
@@ -120,7 +126,7 @@ your plan's usage. It's per player, so in multiplayer everyone picks their own.
The dropdown entries are aliases, which the `claude` CLI resolves to the newest model of that name, The dropdown entries are aliases, which the `claude` CLI resolves to the newest model of that name,
so a new Sonnet or Opus is used automatically. To force any other model, uncomment so a new Sonnet or Opus is used automatically. To force any other model, uncomment
`FACTORIO_ASK_MODEL_OVERRIDE` in `server/claude-co-engineer-ask.service` (an alias or a full model ID) `FACTORIO_ASK_MODEL_OVERRIDE` in `~/.config/systemd/user/claude-co-engineer-ask.service` (an alias or a full model ID)
and run `systemctl --user daemon-reload && systemctl --user restart claude-co-engineer-ask`. It wins and run `systemctl --user daemon-reload && systemctl --user restart claude-co-engineer-ask`. It wins
over the in-game setting for everyone. over the in-game setting for everyone.
@@ -128,7 +134,8 @@ Claude only gets the Factorio tools in these sessions: no shell, files or web. I
running, the game tells you after 3 seconds. The helper's port is a map setting (Ask helper port, default 34201). running, the game tells you after 3 seconds. The helper's port is a map setting (Ask helper port, default 34201).
Helper environment variables: `FACTORIO_ASK_PORT`, `FACTORIO_ASK_MODEL` (model when the game doesn't Helper environment variables: `FACTORIO_ASK_PORT`, `FACTORIO_ASK_MODEL` (model when the game doesn't
send one, default `sonnet`), `FACTORIO_ASK_MODEL_OVERRIDE` (always use this model), send one, default `sonnet`), `FACTORIO_ASK_MODEL_OVERRIDE` (always use this model),
`FACTORIO_ASK_TIMEOUT` (300 s), `FACTORIO_ASK_CLAUDE` (path to the CLI). `FACTORIO_ASK_TIMEOUT` (300 s), `FACTORIO_ASK_MAX_CONCURRENT` (Claude runs at once across all players,
default 2), `FACTORIO_ASK_CLAUDE` (path to the CLI). The helper only accepts packets from the game's UDP port.
## Ratio calculator ## Ratio calculator
+18 -6
View File
@@ -289,9 +289,18 @@ end
-- --------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------
-- Query handlers. Everything here only reads game state, except run_lua which runs whatever it's given. -- Query handlers. Everything here only reads game state, except run_lua which runs whatever it's given.
-- Falls back to the default if the setting isn't registered (e.g. settings.lua changed since startup).
local function global_setting(name, default)
local setting = settings.global[name]
if setting == nil then return default end
return setting.value
end
-- In multiplayer, run_lua runs on every peer and could be steered by text other players put in the world
-- (station names, labels), so it also needs the multiplayer setting, which only admins can change.
local function lua_allowed() local function lua_allowed()
local setting = settings.global["claude-co-engineer-allow-lua"] if not global_setting("claude-co-engineer-allow-lua", true) then return false end
return setting == nil or setting.value return not game.is_multiplayer() or global_setting("claude-co-engineer-allow-lua-multiplayer", false)
end end
function handlers.ping(args, event) function handlers.ping(args, event)
@@ -1595,7 +1604,9 @@ function handlers.screenshot(args, event)
local center = (args.x and args.y) and { x = args.x, y = args.y } or ctx.origin local center = (args.x and args.y) and { x = args.x, y = args.y } or ctx.origin
-- At zoom 1 a tile is 32 pixels. -- At zoom 1 a tile is 32 pixels.
local zoom = width / (width_tiles * 32) local zoom = width / (width_tiles * 32)
local path = "claude-co-engineer/" .. (args.name or ("shot-" .. game.tick)) .. ".jpg" local name = args.name or ("shot-" .. game.tick)
if type(name) ~= "string" or not name:match("^[%w-]+$") then error("name must be letters, digits and dashes", 0) end
local path = "claude-co-engineer/" .. name .. ".jpg"
game.take_screenshot({ game.take_screenshot({
-- In multiplayer only the requesting instance renders; a headless server can't render at all. -- In multiplayer only the requesting instance renders; a headless server can't render at all.
@@ -1710,6 +1721,9 @@ end
function handlers.run_lua(args, event) function handlers.run_lua(args, event)
if not lua_allowed() then if not lua_allowed() then
if game.is_multiplayer() then
error("Lua queries are disabled in multiplayer unless an admin enables 'Allow Lua queries in multiplayer' in the mod settings", 0)
end
error("Lua queries are disabled in the mod settings (Allow Lua queries)", 0) error("Lua queries are disabled in the mod settings (Allow Lua queries)", 0)
end end
local code = args.code or error("code is required") local code = args.code or error("code is required")
@@ -1783,10 +1797,8 @@ function handlers.ask_answer(args, event)
return {} return {}
end end
-- Falls back to the default if the setting isn't registered (e.g. settings.lua changed since startup).
local function ask_port() local function ask_port()
local setting = settings.global["claude-co-engineer-ask-port"] return global_setting("claude-co-engineer-ask-port", 34201)
return setting and setting.value or 34201
end end
local function send_to_helper(player, payload) local function send_to_helper(player, payload)
@@ -1,10 +1,12 @@
[mod-setting-name] [mod-setting-name]
claude-co-engineer-allow-lua=Allow Lua queries claude-co-engineer-allow-lua=Allow Lua queries
claude-co-engineer-allow-lua-multiplayer=Allow Lua queries in multiplayer
claude-co-engineer-ask-port=Ask helper port claude-co-engineer-ask-port=Ask helper port
claude-co-engineer-ask-model=/ask model claude-co-engineer-ask-model=/ask model
[mod-setting-description] [mod-setting-description]
claude-co-engineer-allow-lua=Lets the Co-Engineer run arbitrary Lua to answer questions the built-in queries can't. It is told to only read, but Lua can't be sandboxed to read-only; disable this if you want a hard guarantee that nothing changes. claude-co-engineer-allow-lua=Lets the Co-Engineer run arbitrary Lua to answer questions the built-in queries can't. It is told to only read, but Lua can't be sandboxed to read-only; disable this if you want a hard guarantee that nothing changes.
claude-co-engineer-allow-lua-multiplayer=Also allow Lua queries in multiplayer games. Off by default: the Lua runs for everyone, and text other players put in the world (station names, labels) could talk Claude into changing things.
claude-co-engineer-ask-port=UDP port the claude-co-engineer-ask helper listens on. /ask sends your question there. claude-co-engineer-ask-port=UDP port the claude-co-engineer-ask helper listens on. /ask sends your question there.
claude-co-engineer-ask-model=Which Claude model answers your /ask questions. Faster models use less of your plan's usage; larger ones give more thorough answers. claude-co-engineer-ask-model=Which Claude model answers your /ask questions. Faster models use less of your plan's usage; larger ones give more thorough answers.
+8
View File
@@ -6,6 +6,14 @@ data:extend({
default_value = true, default_value = true,
order = "a", order = "a",
}, },
{
-- Runtime-global settings can only be changed by admins in multiplayer.
type = "bool-setting",
name = "claude-co-engineer-allow-lua-multiplayer",
setting_type = "runtime-global",
default_value = false,
order = "a-b",
},
{ {
type = "int-setting", type = "int-setting",
name = "claude-co-engineer-ask-port", name = "claude-co-engineer-ask-port",
+2 -1
View File
@@ -2,7 +2,8 @@
Description=Claude Co-Engineer: answers in-game /ask questions with Claude Description=Claude Co-Engineer: answers in-game /ask questions with Claude
[Service] [Service]
ExecStart=/home/faab/Documents/Code/Factorio/claude-co-engineer/server/.venv/bin/claude-co-engineer-ask # /path/to/claude-co-engineer is replaced with the repo's location when installing (see README).
ExecStart=/path/to/claude-co-engineer/server/.venv/bin/claude-co-engineer-ask
Environment=FACTORIO_UDP_PORT=34200 Environment=FACTORIO_UDP_PORT=34200
Environment=FACTORIO_ASK_PORT=34201 Environment=FACTORIO_ASK_PORT=34201
# Uncomment to force a model for every /ask, ignoring the in-game setting (alias or full model ID): # Uncomment to force a model for every /ask, ignoring the in-game setting (alias or full model ID):
+24 -9
View File
@@ -23,6 +23,8 @@ DEFAULT_MODEL = os.environ.get("FACTORIO_ASK_MODEL", "sonnet")
# Wins over the in-game setting, e.g. to try a model the dropdown doesn't list yet. # Wins over the in-game setting, e.g. to try a model the dropdown doesn't list yet.
MODEL_OVERRIDE = os.environ.get("FACTORIO_ASK_MODEL_OVERRIDE") MODEL_OVERRIDE = os.environ.get("FACTORIO_ASK_MODEL_OVERRIDE")
TIMEOUT = float(os.environ.get("FACTORIO_ASK_TIMEOUT", "300")) TIMEOUT = float(os.environ.get("FACTORIO_ASK_TIMEOUT", "300"))
MAX_CONCURRENT = int(os.environ.get("FACTORIO_ASK_MAX_CONCURRENT", "2"))
MAX_QUESTION = 2000 # characters; longer questions are cut off
WORKDIR = Path(os.environ.get("FACTORIO_ASK_WORKDIR", "~/.cache/claude-co-engineer/ask")).expanduser() WORKDIR = Path(os.environ.get("FACTORIO_ASK_WORKDIR", "~/.cache/claude-co-engineer/ask")).expanduser()
SYSTEM_PROMPT = """\ SYSTEM_PROMPT = """\
@@ -66,6 +68,8 @@ class AskServer(asyncio.DatagramProtocol):
self.game = GameLink(GAME_PORT) self.game = GameLink(GAME_PORT)
self.sessions: dict[int, str] = {} # player_index -> Claude session id self.sessions: dict[int, str] = {} # player_index -> Claude session id
self.locks: dict[int, asyncio.Lock] = {} self.locks: dict[int, asyncio.Lock] = {}
self.running = asyncio.Semaphore(MAX_CONCURRENT) # claude processes across all players
self.tasks: set[asyncio.Task] = set() # keeps running asks referenced until they finish
WORKDIR.mkdir(parents=True, exist_ok=True) WORKDIR.mkdir(parents=True, exist_ok=True)
self.mcp_config = WORKDIR / "mcp.json" self.mcp_config = WORKDIR / "mcp.json"
self.mcp_config.write_text(json.dumps({ self.mcp_config.write_text(json.dumps({
@@ -79,16 +83,25 @@ class AskServer(asyncio.DatagramProtocol):
})) }))
def datagram_received(self, data: bytes, addr) -> None: def datagram_received(self, data: bytes, addr) -> None:
# The mod sends from the game's --enable-lua-udp port; other local senders are ignored.
if addr[:2] != ("127.0.0.1", GAME_PORT):
return
try: try:
message = json.loads(data) message = json.loads(data)
except ValueError: except ValueError:
return return
player = message.get("player_index") if not isinstance(message, dict) or not isinstance(player := message.get("player_index"), int):
return
if message.get("type") == "reset": if message.get("type") == "reset":
self.sessions.pop(player, None) self.sessions.pop(player, None)
log(f"player {player}: conversation reset") log(f"player {player}: conversation reset")
elif message.get("type") == "ask": elif message.get("type") == "ask":
asyncio.ensure_future(self.handle_ask(message)) ask_id, question = message.get("id"), message.get("question")
if not isinstance(ask_id, int) or not isinstance(question, str) or not question.strip():
return
task = asyncio.ensure_future(self.handle_ask(ask_id, player, question[:MAX_QUESTION], message.get("model")))
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
async def send(self, op: str, **args) -> None: async def send(self, op: str, **args) -> None:
try: try:
@@ -96,14 +109,13 @@ class AskServer(asyncio.DatagramProtocol):
except GameError as e: except GameError as e:
log(f"couldn't reach the game for {op}: {e}") log(f"couldn't reach the game for {op}: {e}")
async def handle_ask(self, message: dict) -> None: async def handle_ask(self, ask_id: int, player: int, question: str, model: str | None) -> None:
ask_id, player, question = message["id"], message.get("player_index"), message["question"] model = MODEL_OVERRIDE or model or DEFAULT_MODEL
model = MODEL_OVERRIDE or message.get("model") or DEFAULT_MODEL if not isinstance(model, str) or not re.fullmatch(r"[\w.-]+", model):
if not re.fullmatch(r"[\w.-]+", model):
model = DEFAULT_MODEL model = DEFAULT_MODEL
log(f"player {player} asks #{ask_id} ({model}): {question}") log(f"player {player} asks #{ask_id} ({model}): {question}")
await self.send("ask_ack", ask_id=ask_id) await self.send("ask_ack", ask_id=ask_id)
async with self.locks.setdefault(player, asyncio.Lock()): async with self.locks.setdefault(player, asyncio.Lock()), self.running:
try: try:
answer, is_error = await asyncio.wait_for(self.run_claude(ask_id, player, question, model), TIMEOUT) answer, is_error = await asyncio.wait_for(self.run_claude(ask_id, player, question, model), TIMEOUT)
except TimeoutError: except TimeoutError:
@@ -114,8 +126,9 @@ class AskServer(asyncio.DatagramProtocol):
await self.send("ask_answer", ask_id=ask_id, player_index=player, text=answer, error=is_error) await self.send("ask_answer", ask_id=ask_id, player_index=player, text=answer, error=is_error)
async def run_claude(self, ask_id: int, player: int, question: str, model: str) -> tuple[str, bool]: async def run_claude(self, ask_id: int, player: int, question: str, model: str) -> tuple[str, bool]:
# The question goes in on stdin: as an argument, one starting with "-" would be parsed as a CLI option.
args = [ args = [
CLAUDE, "-p", question, CLAUDE, "-p",
"--model", model, "--model", model,
"--mcp-config", str(self.mcp_config), "--strict-mcp-config", "--mcp-config", str(self.mcp_config), "--strict-mcp-config",
"--tools", "", # no shell, file or web tools; only the game "--tools", "", # no shell, file or web tools; only the game
@@ -127,9 +140,11 @@ class AskServer(asyncio.DatagramProtocol):
args += ["--resume", self.sessions[player]] args += ["--resume", self.sessions[player]]
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*args, cwd=WORKDIR, stdin=asyncio.subprocess.DEVNULL, *args, cwd=WORKDIR, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024,
) )
process.stdin.write(question.encode())
process.stdin.close()
stderr_task = asyncio.ensure_future(process.stderr.read()) stderr_task = asyncio.ensure_future(process.stderr.read())
result, last_status = None, None result, last_status = None, None
try: try:
+5 -1
View File
@@ -18,7 +18,9 @@ class _Protocol(asyncio.DatagramProtocol):
self.link = link self.link = link
def datagram_received(self, data: bytes, addr: Any) -> None: def datagram_received(self, data: bytes, addr: Any) -> None:
self.link._on_packet(data) # The game replies from its --enable-lua-udp port; ignore anything else on localhost.
if addr[:2] == self.link.peer:
self.link._on_packet(data)
class GameLink: class GameLink:
@@ -39,6 +41,8 @@ class GameLink:
async def _ensure_open(self) -> asyncio.DatagramTransport: async def _ensure_open(self) -> asyncio.DatagramTransport:
if self._transport is None or self._transport.is_closing(): if self._transport is None or self._transport.is_closing():
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# The address replies come from, resolved so a hostname like "localhost" matches.
self.peer = (socket.gethostbyname(self.host), self.game_addr[1])
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Large replies arrive as a burst of ~32KB packets; give the kernel room to queue them. # Large replies arrive as a burst of ~32KB packets; give the kernel room to queue them.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024)
+3 -1
View File
@@ -268,7 +268,9 @@ async def screenshot(
meta = await game.request( meta = await game.request(
"screenshot", x=x, y=y, width_tiles=width_tiles, alt_mode=alt_mode, daylight=daylight, surface=surface, name=name "screenshot", x=x, y=y, width_tiles=width_tiles, alt_mode=alt_mode, daylight=daylight, surface=surface, name=name
) )
path = SCRIPT_OUTPUT / meta.pop("path") # Built from our own name rather than the reply, so a reply can't point the unlink below elsewhere.
meta.pop("path", None)
path = SCRIPT_OUTPUT / "claude-co-engineer" / f"{name}.jpg"
try: try:
data = await _read_finished_jpeg(path) data = await _read_finished_jpeg(path)
finally: finally: