diff --git a/.gitignore b/.gitignore index 8ffb5fc3..82c95b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ coworld/evidence/ !Dockerfile !.dockerignore coworld/**/__pycache__/ +!coworld/gota/conductor/.contracts/ +!coworld/gota/conductor/.contracts/** # Python conversion helpers. __pycache__/ diff --git a/coworld/gota/conductor/.contracts/001-bounded-policies/CONTRACT.md b/coworld/gota/conductor/.contracts/001-bounded-policies/CONTRACT.md new file mode 100644 index 00000000..1f35d965 --- /dev/null +++ b/coworld/gota/conductor/.contracts/001-bounded-policies/CONTRACT.md @@ -0,0 +1,23 @@ +--- +title: Bounded embedded policies +description: Run GOTA policies in the native game with fixed formats, budgets and public outputs. +--- + +# Bounded embedded policies + +## Rules + +- Accept ten BASIC source policies with the declared seat order and byte limit. Do not execute uploaded native code. +- Keep per-tick decisions inside the game. Disable external model and oracle calls for this runtime. +- Use the existing engine and BASIC VM. Configuration cannot supply executable commands or image references. +- Enforce the engine tick limit, host wall-clock budget and outer attempt deadline. Stop the process group on failure. +- Publish scores and the compressed public replay only after the engine stops and output validation succeeds. +- Missing results, compilation failures and execution timeouts fail the attempt. Wait for the atomic result marker + and terminate the remaining server before publishing outputs. +- Keep source programs, private logs and credentials out of the replay and result envelope. +- Build game and runner independently. Pin both images by digest for hosted qualification. + +## Checks + +Run this directory's `check.py` against the composed runner image. It exercises ten actual policies, a complete native +game, scores and compressed replay integrity. Inspect failure cleanup and qualify hosted resource isolation separately. diff --git a/coworld/gota/conductor/.contracts/index.md b/coworld/gota/conductor/.contracts/index.md new file mode 100644 index 00000000..34218d37 --- /dev/null +++ b/coworld/gota/conductor/.contracts/index.md @@ -0,0 +1,3 @@ +# GOTA execution contracts + +- [Bounded embedded policies](001-bounded-policies/CONTRACT.md). diff --git a/coworld/gota/conductor/AGENTS.md b/coworld/gota/conductor/AGENTS.md new file mode 100644 index 00000000..56dd82e1 --- /dev/null +++ b/coworld/gota/conductor/AGENTS.md @@ -0,0 +1,4 @@ +# GOTA in Conductor + +Read the [local contracts](.contracts/index.md) and [operating guide](README.md). +Keep game decisions in the native engine and infrastructure protocol translation in the host. diff --git a/coworld/gota/conductor/Dockerfile b/coworld/gota/conductor/Dockerfile new file mode 100644 index 00000000..157d7e5e --- /dev/null +++ b/coworld/gota/conductor/Dockerfile @@ -0,0 +1,13 @@ +# Build the game and runner artifacts independently, then compose the approved pair. +ARG RUNNER_IMAGE +ARG GAME_IMAGE +FROM ${RUNNER_IMAGE} AS runner +FROM ${GAME_IMAGE} +USER root +RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* +COPY --from=runner /usr/local/bin/node /usr/local/bin/node +COPY --from=runner /opt/worlds/server.mjs /opt/worlds/server.mjs +COPY coworld/gota/conductor/host.py /opt/gota/host.py +RUN useradd --system --create-home world +USER world +ENTRYPOINT ["node", "/opt/worlds/server.mjs"] diff --git a/coworld/gota/conductor/README.md b/coworld/gota/conductor/README.md new file mode 100644 index 00000000..5808a520 --- /dev/null +++ b/coworld/gota/conductor/README.md @@ -0,0 +1,19 @@ +# GotA in Universe + +The game executes ten BASIC policies inside its existing native VM loop. Conductor owns the enclosing process, +deadline and result transport. A policy gets its own hero's observation and cannot access another policy's source. + +The [host](host.py) accepts Conductor process input, stages the existing Coworld file protocol, and publishes the +result and compressed replay after the native process stops. It accepts raw UTF-8 BASIC up to 1 MiB per seat. +LLM network calls are disabled for this fast profile. The game must finish within 60 seconds of host startup and +within the attempt's outer deadline. A slow interactive coaching variant needs a separate, qualified runtime. + +Build the game with `coworld/Dockerfile`, using `GAME=gods_of_the_arena` and `ENTRY=gota`. Build Conductor's runner +artifact independently. Compose them with this directory's Dockerfile, passing `GAME_IMAGE` and `RUNNER_IMAGE`. +Hosted preparation must pin both images by digest. Local development may use explicit local image tags. + +Register command `["python3", "/opt/gota/host.py"]`, policy format `basic-source-v1`, and a 60-second outer +budget (including allocation). Publish [world.json](world.json) using the installed runtime's immutable name. +Seat IDs are `seat-0` through `seat-9`; the first five are red and the last five blue. + +Private policy logs remain inside the attempt. The public replay excludes policy source and PRINT output. diff --git a/coworld/gota/conductor/check.py b/coworld/gota/conductor/check.py new file mode 100644 index 00000000..9fa95aa0 --- /dev/null +++ b/coworld/gota/conductor/check.py @@ -0,0 +1,121 @@ +"""Qualify a native GotA game, replay publication, and the outer execution budget.""" + +import argparse +import gzip +import hashlib +import json +import secrets +import subprocess +import time +import uuid +from pathlib import Path +from urllib.error import URLError +from urllib.request import Request, urlopen + + +def docker(*args): + return subprocess.check_output(["docker", *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("image") + args = parser.parse_args() + token = secrets.token_hex(32) + container = docker( + "run", + "--detach", + "--rm", + "--name", + "gota-participation-" + uuid.uuid4().hex[:10], + "--label", + "owner=gota-participation-qualification", + "--platform", + "linux/amd64", + "--cap-drop=ALL", + "--security-opt", + "no-new-privileges", + "--memory", + "1g", + "--cpus", + "2", + "-p", + "127.0.0.1::8080", + "-e", + "RUNNER_TOKEN=" + token, + "-e", + "WORLD_RUNTIME=gota", + "-e", + 'WORLD_COMMAND=["python3","/opt/gota/host.py"]', + args.image, + ) + boot = None + try: + origin = "http://127.0.0.1:" + docker("port", container, "8080/tcp").rsplit(":", 1)[1] + + def call(path, body=None, method=None, raw=False): + headers = {"Authorization": "Bearer " + token} + if boot: + headers["x-runner-boot"] = boot + if isinstance(body, dict): + body = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + with urlopen(Request(origin + path, data=body, headers=headers, method=method), timeout=10) as response: + data = response.read() + return data if raw or not data else json.loads(data) + + for _ in range(100): + try: + boot = call("/health")["bootId"] + break + except (URLError, ConnectionError): + time.sleep(0.1) + assert boot, "Runner did not start" + policies = [] + for slot in range(10): + file = Path(__file__).parent.parent / "players" / ("rusher.bas" if slot < 5 else "puller.bas") + source = file.read_bytes() + digest = hashlib.sha256(source).hexdigest() + call("/inputs/" + digest, source, "PUT") + policies.append( + {"role": f"seat-{slot}", "format": "basic-source-v1", "blob": {"digest": digest, "bytes": len(source)}} + ) + attempt = secrets.token_hex(32) + path = "/attempts/" + attempt + started = time.monotonic() + call( + path, + { + "protocol": 1, + "id": attempt, + "runId": attempt, + "runtime": "gota", + "deadlineAt": int(time.time() * 1000) + 120_000, + "seed": 0, + "policies": policies, + "config": {"max_ticks": 28800}, + }, + "PUT", + ) + while True: + result = call(path) + if result["status"] not in ("running", "queued"): + break + assert time.monotonic() - started < 70, "Native game exceeded its 60 second budget" + time.sleep(0.2) + assert result["status"] == "succeeded", result + assert len(result["result"]["scores"]) == 10, result + replay = call(path + "/outputs/replay", raw=True) + decoded = gzip.decompress(replay) + assert len(decoded) > 1000, "Replay was empty" + assert hashlib.sha256(replay).hexdigest() == result["outputs"][0]["digest"] + print( + f"Qualified: ten embedded policies, complete game, hashed replay; " + f"{time.monotonic() - started:.2f}s, {len(replay)} replay bytes." + ) + finally: + subprocess.run(["docker", "stop", "--time", "5", container], check=False, stdout=subprocess.DEVNULL) + + +if __name__ == "__main__": + main() diff --git a/coworld/gota/conductor/host.py b/coworld/gota/conductor/host.py new file mode 100644 index 00000000..0efd4b01 --- /dev/null +++ b/coworld/gota/conductor/host.py @@ -0,0 +1,115 @@ +"""Run the native GotA engine through Conductor's process protocol.""" + +import gzip +import hashlib +import json +import os +import secrets +import subprocess +import time +from pathlib import Path + + +def run(): + root = Path(os.environ["WORLD_SCRATCH_PATH"]) + request = json.loads(Path(os.environ["WORLD_INPUT_PATH"]).read_text()) + policies = {policy["role"]: policy for policy in request["policies"]} + roles = [f"seat-{slot}" for slot in range(10)] + if request.get("seats") or set(policies) != set(roles): + raise ValueError("GotA requires ten embedded seats") + supplied = request["config"] + if set(supplied) - {"max_ticks", "draft_mode"}: + raise ValueError("Unknown GotA configuration") + ticks = supplied.get("max_ticks", 28_800) + if type(ticks) is not int or not 1 <= ticks <= 28_800: + raise ValueError("Invalid tick budget") + draft_mode = supplied.get("draft_mode", "open") + if draft_mode not in ("open", "team"): + raise ValueError("Invalid draft mode") + seed = request["seed"] + if seed >= 2**31: + seed -= 2**32 + config = {"seed": seed, "max_ticks": ticks, "draft_mode": draft_mode, "tokens": [], "players": []} + seats = [] + for slot, role in enumerate(roles): + policy = policies[role] + if policy["format"] != "basic-source-v1": + raise ValueError("Unsupported policy format") + source = Path(policy["path"]) + if source.stat().st_size > 1024 * 1024: + raise ValueError("Policy is too large") + body = source.read_bytes() + body.decode("utf-8") + config["tokens"].append(secrets.token_hex(24)) + config["players"].append({"name": role}) + seats.append( + { + "slot": slot, + "file_uri": source.as_uri(), + "content_hash": hashlib.sha256(body).hexdigest(), + "size_bytes": len(body), + "log_uri": (root / f"private-{slot}.log").as_uri(), + "artifact_uri": (root / f"private-{slot}.zip").as_uri(), + } + ) + files = { + "CONFIG": ("game-config.json", config), + "PLAYER_SEATS": ("game-seats.json", {"schema": "coworld-player-seats/1", "seats": seats}), + } + environment = { + "PATH": "/usr/local/bin:/usr/bin:/bin", + "HOME": str(root), + "COGAME_LLM": "off", + "COGAME_ORACLE": "off", + "COGAME_HOST": "127.0.0.1", + "COGAME_PORT": "8081", + } + for key, (name, value) in files.items(): + path = root / name + path.write_text(json.dumps(value)) + environment[f"COGAME_{key}_URI"] = path.as_uri() + for key, name in ( + ("RESULTS", "game-result.json"), + ("SAVE_REPLAY", "game-replay"), + ("PLAYER_FAILURE", "game-failure.json"), + ): + environment[f"COGAME_{key}_URI"] = (root / name).as_uri() + deadline = min(request["deadlineAt"] / 1000, time.time() + 60) + child = subprocess.Popen( + ["/usr/local/bin/polyworld"], env=environment, cwd=root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + try: + while not (root / "game-result.json").exists(): + if (root / "game-failure.json").exists(): + raise RuntimeError("Policy execution failed") + if child.poll() is not None: + raise RuntimeError("Game exited before publishing its result") + if time.time() >= deadline: + raise TimeoutError("GotA exceeded its execution budget") + time.sleep(0.02) + finally: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + result = json.loads((root / "game-result.json").read_text()) + if len(result.get("scores", [])) != 10: + raise ValueError("Invalid GotA result") + with (root / "game-replay").open("rb") as source, gzip.open(root / "replay.gz", "wb") as target: + while block := source.read(64 * 1024): + target.write(block) + output = { + "result": result, + "artifacts": [{"name": "replay", "path": "replay.gz", "mediaType": "application/gzip"}], + } + path = Path(os.environ["WORLD_OUTPUT_PATH"]) + temporary = path.with_suffix(".pending") + temporary.write_text(json.dumps(output)) + temporary.replace(path) + + +if __name__ == "__main__": + run() diff --git a/coworld/gota/conductor/world.json b/coworld/gota/conductor/world.json new file mode 100644 index 00000000..ab0bf389 --- /dev/null +++ b/coworld/gota/conductor/world.json @@ -0,0 +1,56 @@ +{ + "name": "gota", + "runtime": "gota-local", + "roles": [ + { + "id": "seat-0", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-1", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-2", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-3", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-4", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-5", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-6", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-7", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-8", + "policyFormat": "basic-source-v1", + "control": "embedded" + }, + { + "id": "seat-9", + "policyFormat": "basic-source-v1", + "control": "embedded" + } + ] +}