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

+2 -1
View File
@@ -2,7 +2,8 @@
Description=Claude Co-Engineer: answers in-game /ask questions with Claude
[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_ASK_PORT=34201
# 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.
MODEL_OVERRIDE = os.environ.get("FACTORIO_ASK_MODEL_OVERRIDE")
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()
SYSTEM_PROMPT = """\
@@ -66,6 +68,8 @@ class AskServer(asyncio.DatagramProtocol):
self.game = GameLink(GAME_PORT)
self.sessions: dict[int, str] = {} # player_index -> Claude session id
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)
self.mcp_config = WORKDIR / "mcp.json"
self.mcp_config.write_text(json.dumps({
@@ -79,16 +83,25 @@ class AskServer(asyncio.DatagramProtocol):
}))
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:
message = json.loads(data)
except ValueError:
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":
self.sessions.pop(player, None)
log(f"player {player}: conversation reset")
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:
try:
@@ -96,14 +109,13 @@ class AskServer(asyncio.DatagramProtocol):
except GameError as e:
log(f"couldn't reach the game for {op}: {e}")
async def handle_ask(self, message: dict) -> None:
ask_id, player, question = message["id"], message.get("player_index"), message["question"]
model = MODEL_OVERRIDE or message.get("model") or DEFAULT_MODEL
if not re.fullmatch(r"[\w.-]+", model):
async def handle_ask(self, ask_id: int, player: int, question: str, model: str | None) -> None:
model = MODEL_OVERRIDE or model or DEFAULT_MODEL
if not isinstance(model, str) or not re.fullmatch(r"[\w.-]+", model):
model = DEFAULT_MODEL
log(f"player {player} asks #{ask_id} ({model}): {question}")
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:
answer, is_error = await asyncio.wait_for(self.run_claude(ask_id, player, question, model), TIMEOUT)
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)
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 = [
CLAUDE, "-p", question,
CLAUDE, "-p",
"--model", model,
"--mcp-config", str(self.mcp_config), "--strict-mcp-config",
"--tools", "", # no shell, file or web tools; only the game
@@ -127,9 +140,11 @@ class AskServer(asyncio.DatagramProtocol):
args += ["--resume", self.sessions[player]]
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,
)
process.stdin.write(question.encode())
process.stdin.close()
stderr_task = asyncio.ensure_future(process.stderr.read())
result, last_status = None, None
try:
+5 -1
View File
@@ -18,7 +18,9 @@ class _Protocol(asyncio.DatagramProtocol):
self.link = link
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:
@@ -39,6 +41,8 @@ class GameLink:
async def _ensure_open(self) -> asyncio.DatagramTransport:
if self._transport is None or self._transport.is_closing():
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)
# 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)
+3 -1
View File
@@ -268,7 +268,9 @@ async def screenshot(
meta = await game.request(
"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:
data = await _read_finished_jpeg(path)
finally: