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:
8 files changed
+79
-28
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user