gameye-mcp 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,8 +12,23 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server for the [Game
12
12
  GAMEYE_API_TOKEN=<your-token> npx -y gameye-mcp
13
13
  ```
14
14
 
15
+ No Gameye account yet? Start it without a token, `npx -y gameye-mcp`, and your agent can still read the bundled Gameye skills and check a Dockerfile. See [Without a token](#without-a-token).
16
+
15
17
  It needs Node.js 22 or newer. The server speaks MCP over stdio. Normally your MCP client starts it for you (see [Install](#install)), so you rarely need to run it by hand.
16
18
 
19
+ ## Without a token
20
+
21
+ You can connect the server before you have a Gameye account. Leave `GAMEYE_API_TOKEN` out of any install below, and the server starts in no-token mode and prints one line on stderr saying so.
22
+
23
+ - These tools work, and send nothing to the Gameye API: `gameye_context`, `gameye_list_skills` and `gameye_get_skill` (the Gameye skills bundled with the server), `gameye_get_template`, and `gameye_check_dockerfile`. `gameye_update_skills` works too, and reaches GitHub only when called.
24
+ - Every other tool is an account tool. Without a token it sends nothing and returns an error that starts with `Token required:`, then gives the setup steps and the narrowest [token scopes](#token-scopes) for that tool. A `dryRun` call is refused the same way.
25
+ - Every tool result shows `· no token` after the host, for example `[sandbox · api.sandbox-gameye.gameye.net · no token]`, and `gameye_context` reports `Token: not configured`.
26
+ - The instructions the server sends to a connecting agent name the tools that work and tell it to read the `gameye` skill first, then the skill for the game's engine.
27
+
28
+ To get a token, sign up at [trial.gameye.com](https://trial.gameye.com/?utm_source=github&utm_medium=readme&utm_campaign=gameye-mcp), then in the Gameye Admin Panel open **Users → API tokens** and create one. Self-serve accounts use the sandbox host, which is the server's default.
29
+
30
+ Set the token where the server is configured: the `env` block of your MCP client's server entry, or `--env` when you add it with `claude mcp add`. The server reads `GAMEYE_API_TOKEN` once when it starts, so restart or reconnect it after you set or change the token. Never paste the token into the chat with your agent.
31
+
17
32
  ## Environments
18
33
 
19
34
  Gameye has two public API hosts. The server targets **sandbox** by default.
@@ -29,10 +44,14 @@ The environment is a property of the server process. No tool takes an environmen
29
44
 
30
45
  | Variable | Required | Default | Description |
31
46
  |---|---|---|---|
32
- | `GAMEYE_API_TOKEN` | **Yes** | — | Gameye API token. Use the least-privileged scopes you need (see below). |
47
+ | `GAMEYE_API_TOKEN` | No | — | Gameye API token. Use the least-privileged scopes you need (see [Token scopes](#token-scopes)). Without it the server starts in no-token mode: see [Without a token](#without-a-token). |
33
48
  | `GAMEYE_ENV` | No | `sandbox` | `sandbox` or `production`. Selects the API host. |
34
49
  | `GAMEYE_API_URL` | No | — | Overrides the host, for testing only. Must be `https`. If it equals one of the two known hosts, it must match `GAMEYE_ENV` or the server refuses to start. Any other URL is labelled `custom` in every tool result. |
35
50
  | `GAMEYE_ENABLE_SKILLS_UPDATE` | No | — | Set to `1` to let `gameye_update_skills` run the skills updaters with `apply: true`. Without it the tool only checks and lists the commands. |
51
+ | `GAMEYE_MAX_SESSIONS` | No | `5` | Most sessions launched by this server that may run at once. A launch past it is refused. |
52
+ | `GAMEYE_MAX_TTL` | No | `4h` | Longest `ttl` a launch may ask for, at least `30m`. A longer TTL, or `0`, is refused. |
53
+ | `GAMEYE_MAX_LAUNCHES` | No | `20` | Launches per server run. Restart the server to reset it. |
54
+ | `GAMEYE_STOP_OWN_ONLY` | No | — | Set to `1` so `gameye_stop_session` only stops sessions this server launched. |
36
55
  | `GAMEYE_TIMEOUT_MS` | No | `30000` | Per-request timeout in milliseconds, a positive whole number. Log snapshots get at least 60 s and artifact downloads at least 120 s. A request that runs out of time comes back as a tool error: `Request to <host> timed out after <n>s`. |
37
56
 
38
57
  ## Install
@@ -66,7 +85,7 @@ Most clients (Claude Desktop, Cursor, Windsurf and others) accept an `mcpServers
66
85
 
67
86
  Restart the client after you change its configuration.
68
87
 
69
- `npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.3.2` (or `"args": ["-y", "gameye-mcp@0.3.2"]`).
88
+ `npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.4.0` (or `"args": ["-y", "gameye-mcp@0.4.0"]`).
70
89
 
71
90
  ### Windows
72
91
 
@@ -108,7 +127,7 @@ With Claude Code: `claude mcp add gameye --env GAMEYE_API_TOKEN=<your-token> --
108
127
 
109
128
  - Pass each variable with `-e NAME` (no value), so Docker copies it from the client's environment and the token never appears in the command line.
110
129
  - Keep `-i` and never add `-t`: a terminal mangles the JSON-RPC messages on stdout.
111
- - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.3.2`.
130
+ - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.4.0`.
112
131
  - Files the server writes stay inside the container, and `--rm` deletes them:
113
132
  - `gameye_get_artifacts` saves its download under the temp directory. To keep it, mount a directory and point the temp directory at it: `-v /absolute/dir:/out -e TMPDIR=/out`. The path the tool reports is the container path; the file is in `/absolute/dir` on the host.
114
133
  - `gameye_create_token` writes to the `outputPath` you give it. Mount a directory the same way and pass an `outputPath` under `/out`.
@@ -129,29 +148,35 @@ Give the server a token with only the scopes it needs.
129
148
 
130
149
  ## Available tools
131
150
 
132
- Every tool result starts with the environment and host it used, for example `[sandbox · api.sandbox-gameye.gameye.net]`.
151
+ Every tool result starts with the environment and host it used, for example `[sandbox · api.sandbox-gameye.gameye.net]`. With no token configured it ends with `· no token`: `[sandbox · api.sandbox-gameye.gameye.net · no token]`.
133
152
 
134
- | Tool | Kind | Description |
135
- |---|---|---|
136
- | `gameye_context` | read | Show the environment, host and server version this server targets, and the guardrail defaults (session TTL, `launched-by` label, token expiry) |
137
- | `gameye_launch_session` | write, `dryRun` | Start a containerized game server (billable). Default TTL and agent label applied; see [Guardrails](#guardrails) |
138
- | `gameye_list_sessions` | read | List running sessions (filterable; `all=true` for every status) |
139
- | `gameye_describe_session` | read | Get session details, ports and players |
140
- | `gameye_stop_session` | destructive, `dryRun` | Stop one session by its exact ID |
141
- | `gameye_player_join` | write | Register one or more players as joined |
142
- | `gameye_player_leave` | write | Remove one or more players from a session |
143
- | `gameye_get_locations` | read | List locations that can host an image right now |
144
- | `gameye_list_regions` | read | List the regions available to your organization |
145
- | `gameye_check_tag` | read | Check whether an image tag is available in a region |
146
- | `gameye_list_applications` | read | List your applications with registry, resources, regions and ports |
147
- | `gameye_list_registry_tags` | read | List an application's tags in its upstream registry (paged) |
148
- | `gameye_enable_tag` | write, `dryRun` | Enable an image tag for an application so Gameye preloads it |
149
- | `gameye_get_logs` | read | Fetch session logs |
150
- | `gameye_get_artifacts` | read | Download a file or directory from a session container as a tar archive |
151
- | `gameye_create_application` | write, `dryRun` | Create an application |
152
- | `gameye_edit_application` | destructive, `dryRun` | Change an application; omitted fields stay unchanged |
153
- | `gameye_create_token` | write, `dryRun` | Create an API token and write it to a file |
154
- | `gameye_update_skills` | read, or write with `apply` | Compare the installed [Gameye skills](https://github.com/gameye/gameye-skills) with the latest pack, and update them with `apply: true`. See [Keeping the skills current](#keeping-the-skills-current) |
153
+ The Token column names the narrowest [token scope recipe](#token-scopes) each tool needs. Tools marked `none` work without a token.
154
+
155
+ | Tool | Kind | Token | Description |
156
+ |---|---|---|---|
157
+ | `gameye_context` | read | none | Show the environment, host and server version this server targets, and the guardrail defaults (session TTL, `launched-by` label, token expiry) |
158
+ | `gameye_launch_session` | write, `dryRun` | Operator | Start a containerized game server (billable). Default TTL and agent label applied; see [Guardrails](#guardrails) |
159
+ | `gameye_list_sessions` | read | Observer | List running sessions (filterable; `all=true` for every status) |
160
+ | `gameye_describe_session` | read | Observer | Get session details, ports and players |
161
+ | `gameye_stop_session` | destructive, `dryRun` | Operator | Stop one session by its exact ID |
162
+ | `gameye_player_join` | write | Operator | Register one or more players as joined |
163
+ | `gameye_player_leave` | write | Operator | Remove one or more players from a session |
164
+ | `gameye_get_locations` | read | Observer | List locations that can host an image right now |
165
+ | `gameye_list_regions` | read | Observer | List the regions available to your organization |
166
+ | `gameye_check_tag` | read | Observer | Check whether an image tag is available in a region |
167
+ | `gameye_list_applications` | read | Observer | List your applications with registry, resources, regions and ports |
168
+ | `gameye_list_registry_tags` | read | Observer | List an application's tags in its upstream registry (paged) |
169
+ | `gameye_enable_tag` | write, `dryRun` | Deployer | Enable an image tag for an application so Gameye preloads it |
170
+ | `gameye_get_logs` | read | Observer | Fetch session logs |
171
+ | `gameye_get_artifacts` | read | Observer | Download a file or directory from a session container as a tar archive |
172
+ | `gameye_create_application` | write, `dryRun` | Deployer | Create an application |
173
+ | `gameye_edit_application` | destructive, `dryRun` | Deployer | Change an application; omitted fields stay unchanged |
174
+ | `gameye_create_token` | write, `dryRun` | Token admin | Create an API token and write it to a file |
175
+ | `gameye_update_skills` | read, or write with `apply` | none | Compare the installed [Gameye skills](https://github.com/gameye/gameye-skills) with the latest pack, and update them with `apply: true`. See [Keeping the skills current](#keeping-the-skills-current) |
176
+ | `gameye_list_skills` | read | none | List the Gameye skills bundled with this server, with each skill's version and files |
177
+ | `gameye_get_skill` | read | none | Read a bundled skill's `SKILL.md` or one of its reference files |
178
+ | `gameye_get_template` | read | none | Get the Dockerfile or shutdown-hook template for an Unreal, Unity, Godot or web server, exactly as published in the bundled skill |
179
+ | `gameye_check_dockerfile` | read | none | Check Dockerfile text against the [gameye-containerize](https://github.com/gameye/gameye-skills) container contract, offline: findings with severity, rule, line and fix, plus what the check cannot know |
155
180
 
156
181
  Every tool carries MCP annotations: read tools set `readOnlyHint`, `gameye_stop_session` and `gameye_edit_application` set `destructiveHint`, and every tool that calls the Gameye API sets `openWorldHint`.
157
182
 
@@ -165,7 +190,8 @@ These behave the same on sandbox and production, because sandbox runs live match
165
190
  - **Bounded sessions.** A launch without `ttl` gets a `30m` TTL, and the result says the default was applied. Every launch sends the label `launched-by: gameye-mcp`, replacing any `launched-by` value the caller passes, so agent-launched sessions can be found with `gameye_list_sessions` and `filter: { "launched-by": "gameye-mcp" }`. When a warm pool serves the image and region, the Gameye API ignores request labels (and env and args), so warm-pool sessions do not carry this label.
166
191
  - **No token in game servers.** A launch is refused, with nothing sent, if any env value or name, arg, or label key or value contains this server's Gameye API token. Env values are never shown in results.
167
192
  - **No token in results.** Every tool result, including errors, has this server's API token replaced with `***`, in case log output, labels or an error message echo it.
168
- - **Explicit stops.** `gameye_stop_session` stops exactly one session by its exact ID. There is no bulk stop, and empty, wildcard or list IDs are refused.
193
+ - **Explicit stops.** `gameye_stop_session` stops exactly one session by its exact ID. There is no bulk stop, and empty, wildcard or list IDs are refused. With `GAMEYE_STOP_OWN_ONLY=1` it also refuses any session this server did not launch.
194
+ - **Session limits.** The Gameye API has no per-token budget, so the server enforces one before it sends a launch. By default an agent can have at most 5 sessions it launched running at once (`GAMEYE_MAX_SESSIONS`), each with a TTL of at most `4h` (`GAMEYE_MAX_TTL`), and at most 20 launches per server run (`GAMEYE_MAX_LAUNCHES`). That caps one run at 20 sessions of 4 hours, 80 session-hours, and 5 at a time. Before each launch the server lists the `launched-by: gameye-mcp` sessions to see which of its own have ended; if that list fails, the launch is refused. Warm-pool sessions carry no label, so they count until their TTL ends. A launch that timed out or got a 5xx may have started, so it counts too. The limits apply to one server process: sessions launched by other agents or tools in your organization don't count, and a restart starts from zero, so set an organization quota in Gameye as well. `gameye_context` shows the limits in force.
169
195
 
170
196
  ## Keeping the skills current
171
197
 
@@ -175,7 +201,7 @@ The [Gameye skills](https://github.com/gameye/gameye-skills) change as the Gamey
175
201
  - **skills CLI, this project:** the same, in `skills-lock.json` in the server's working directory.
176
202
  - **Claude Code plugin:** `gameye@gameye` in `~/.claude/plugins/installed_plugins.json`.
177
203
 
178
- Without `apply` it only reports. With `apply: true`, and only when the server was started with `GAMEYE_ENABLE_SKILLS_UPDATE=1`, it runs each outdated install's own updater: `npx -y skills@1.7.4 update <the Gameye skills> -g -y` (or `-p -y`), or `claude plugin marketplace update gameye` then `claude plugin update gameye@gameye`. Otherwise it lists those commands for you to run. The updaters get a minimal environment: paths, home, temp, locale, proxy and CA settings and npm and Claude config, never `GAMEYE_*` variables or anything named like a token, secret, password or key. It only updates skills whose lock-file source is `gameye/gameye-skills`, and it never downloads or follows skill text itself. Each command runs with stdin closed and a 90-second limit. On Windows it prints the commands instead of running them. The running agent session keeps the skills it loaded; the next session uses the update. The `gameye` skill tells agents to run the check once per conversation and ask before applying.
204
+ Without `apply` it only reports. With `apply: true`, and only when the server was started with `GAMEYE_ENABLE_SKILLS_UPDATE=1`, it runs each outdated install's own updater: `npx -y skills@1.7.4 update <the Gameye skills> -g -y` (or `-p -y`), or `claude plugin marketplace update gameye` then `claude plugin update gameye@gameye`. Otherwise it lists those commands for you to run. The updaters get a minimal environment: paths, home, temp, locale, proxy and CA settings and npm and Claude config, never `GAMEYE_*` variables or anything named like a token, secret, password or key. It only updates skills whose lock-file source is `gameye/gameye-skills`, and it ships a pinned copy of the skills reviewed at build time: the skill and template tools never fetch skill text at runtime, and only `gameye_update_skills` reaches GitHub, when called. Each command runs with stdin closed and a 90-second limit. On Windows it prints the commands instead of running them. The running agent session keeps the skills it loaded; the next session uses the update. The `gameye` skill tells agents to run the check once per conversation and ask before applying.
179
205
 
180
206
  ## Telemetry/attribution
181
207
 
@@ -203,6 +229,8 @@ A port with `tls: true` has TLS terminated by Gameye on the host. Set it with `g
203
229
 
204
230
  To teach your agent how to integrate Gameye (session lifecycle, matchmaking, webhooks and more), install the sister project [gameye-skills](https://github.com/gameye/gameye-skills). The skills use these MCP tools when they are available, and fall back to plain HTTP when they aren't.
205
231
 
232
+ The server also bundles a pinned copy of the skills, which an agent reads with `gameye_list_skills` and `gameye_get_skill`, with or without a token. If an installed copy is newer, it wins.
233
+
206
234
  ## Development
207
235
 
208
236
  See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,84 @@
1
+ export type Severity = "error" | "warning";
2
+ export type Protocol = "tcp" | "udp";
3
+ export interface DeclaredPort {
4
+ port: number;
5
+ protocol: Protocol;
6
+ }
7
+ export interface DockerfileFinding {
8
+ severity: Severity;
9
+ code: RuleCode;
10
+ /** 1-based line where the offending instruction starts. */
11
+ line: number;
12
+ message: string;
13
+ fix: string;
14
+ }
15
+ export interface DockerfileCheckResult {
16
+ findings: DockerfileFinding[];
17
+ }
18
+ export interface DockerfileRule {
19
+ code: string;
20
+ /** Number of the item in the gameye-containerize "container contract" list. */
21
+ contractNumber: number;
22
+ /** Text that must appear in that contract item. */
23
+ keyword: string;
24
+ severity: Severity;
25
+ }
26
+ export declare const DOCKERFILE_RULES: readonly [{
27
+ readonly code: "GC1-arm64-platform";
28
+ readonly contractNumber: 1;
29
+ readonly keyword: "linux/amd64";
30
+ readonly severity: "error";
31
+ }, {
32
+ readonly code: "GC1-windows-base";
33
+ readonly contractNumber: 1;
34
+ readonly keyword: "Windows containers";
35
+ readonly severity: "error";
36
+ }, {
37
+ readonly code: "GC2-expose-missing";
38
+ readonly contractNumber: 2;
39
+ readonly keyword: "EXPOSE";
40
+ readonly severity: "error";
41
+ }, {
42
+ readonly code: "GC2-port-mismatch";
43
+ readonly contractNumber: 2;
44
+ readonly keyword: "EXPOSE";
45
+ readonly severity: "error";
46
+ }, {
47
+ readonly code: "GC2-loopback-bind";
48
+ readonly contractNumber: 2;
49
+ readonly keyword: "0.0.0.0";
50
+ readonly severity: "error";
51
+ }, {
52
+ readonly code: "GC4-exec-form";
53
+ readonly contractNumber: 4;
54
+ readonly keyword: "SIGTERM";
55
+ readonly severity: "error";
56
+ }, {
57
+ readonly code: "GC4-npm-start";
58
+ readonly contractNumber: 4;
59
+ readonly keyword: "SIGTERM";
60
+ readonly severity: "error";
61
+ }, {
62
+ readonly code: "GC7-non-root";
63
+ readonly contractNumber: 7;
64
+ readonly keyword: "non-root";
65
+ readonly severity: "warning";
66
+ }, {
67
+ readonly code: "GC9-token-in-image";
68
+ readonly contractNumber: 9;
69
+ readonly keyword: "API token";
70
+ readonly severity: "error";
71
+ }];
72
+ export type RuleCode = (typeof DOCKERFILE_RULES)[number]["code"];
73
+ export declare const CANNOT_KNOW: string;
74
+ export interface Instruction {
75
+ keyword: string;
76
+ args: string;
77
+ line: number;
78
+ }
79
+ /** Split Dockerfile text into instructions, joining continuations and skipping comments and heredoc bodies. */
80
+ export declare function parseDockerfile(text: string): Instruction[];
81
+ export declare function checkDockerfile(text: string, opts?: {
82
+ ports?: DeclaredPort[];
83
+ }): DockerfileCheckResult;
84
+ export declare function formatDockerfileReport(result: DockerfileCheckResult): string;
@@ -0,0 +1,278 @@
1
+ // Offline Dockerfile check against the gameye-containerize container contract.
2
+ //
3
+ // A pure function over Dockerfile text: it reads no files, spawns nothing and
4
+ // makes no request. Each rule cites the numbered contract item it enforces, so
5
+ // a later test can check the numbers against the bundled skill. Any value the
6
+ // check flags as a credential is redacted to `***` here, by the check itself;
7
+ // the server's own token masking does not know about values in user input.
8
+ import { MASK } from "./client.js";
9
+ import { GuardError } from "./guards.js";
10
+ export const DOCKERFILE_RULES = [
11
+ { code: "GC1-arm64-platform", contractNumber: 1, keyword: "linux/amd64", severity: "error" },
12
+ { code: "GC1-windows-base", contractNumber: 1, keyword: "Windows containers", severity: "error" },
13
+ { code: "GC2-expose-missing", contractNumber: 2, keyword: "EXPOSE", severity: "error" },
14
+ { code: "GC2-port-mismatch", contractNumber: 2, keyword: "EXPOSE", severity: "error" },
15
+ { code: "GC2-loopback-bind", contractNumber: 2, keyword: "0.0.0.0", severity: "error" },
16
+ { code: "GC4-exec-form", contractNumber: 4, keyword: "SIGTERM", severity: "error" },
17
+ { code: "GC4-npm-start", contractNumber: 4, keyword: "SIGTERM", severity: "error" },
18
+ { code: "GC7-non-root", contractNumber: 7, keyword: "non-root", severity: "warning" },
19
+ { code: "GC9-token-in-image", contractNumber: 9, keyword: "API token", severity: "error" },
20
+ ];
21
+ export const CANNOT_KNOW = "What this check cannot know: whether the image is pushed to a registry Gameye can pull from, " +
22
+ "whether your application's port bindings in the Gameye Admin Panel match these ports and protocols, " +
23
+ "and whether the server binary starts, binds its ports and exits on SIGTERM. It reads only the Dockerfile " +
24
+ "text, not entrypoint scripts, build output or the base image's defaults. A clean result means no contract " +
25
+ "break was found in this text, nothing more; test the image locally before launching it.";
26
+ const SKILL = "gameye-containerize";
27
+ /** Split Dockerfile text into instructions, joining continuations and skipping comments and heredoc bodies. */
28
+ export function parseDockerfile(text) {
29
+ const lines = text.split(/\r?\n/);
30
+ const escapeMatch = /^#\s*escape\s*=\s*([`\\])\s*$/i.exec(lines[0] ?? "");
31
+ const escape = escapeMatch ? escapeMatch[1] : "\\";
32
+ const out = [];
33
+ let i = 0;
34
+ while (i < lines.length) {
35
+ const raw = lines[i];
36
+ const trimmed = raw.trim();
37
+ if (trimmed === "" || trimmed.startsWith("#")) {
38
+ i++;
39
+ continue;
40
+ }
41
+ const start = i + 1;
42
+ let joined = "";
43
+ let current = raw;
44
+ for (;;) {
45
+ const t = current.trimEnd();
46
+ if (t.endsWith(escape)) {
47
+ joined += t.slice(0, -1) + " ";
48
+ i++;
49
+ // Comment and empty lines inside a continuation are dropped.
50
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].trim().startsWith("#")))
51
+ i++;
52
+ if (i >= lines.length)
53
+ break;
54
+ current = lines[i];
55
+ }
56
+ else {
57
+ joined += t;
58
+ i++;
59
+ break;
60
+ }
61
+ }
62
+ const m = /^\s*(\S+)\s*(.*)$/s.exec(joined);
63
+ if (!m)
64
+ continue;
65
+ const keyword = m[1].toUpperCase();
66
+ const args = m[2].trim();
67
+ out.push({ keyword, args, line: start });
68
+ // Skip heredoc bodies (RUN <<EOT ... EOT) so their lines are not read as instructions.
69
+ for (const h of args.matchAll(/<<(-?)\s*["']?([A-Za-z_][A-Za-z0-9_]*)["']?/g)) {
70
+ const [, dash, delim] = h;
71
+ while (i < lines.length) {
72
+ const l = dash ? lines[i].replace(/^\t+/, "") : lines[i];
73
+ i++;
74
+ if (l.trim() === delim)
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function splitStages(instructions) {
82
+ const stages = [];
83
+ for (const ins of instructions) {
84
+ if (ins.keyword === "FROM") {
85
+ const words = ins.args.split(/\s+/).filter(Boolean);
86
+ let platform;
87
+ const rest = [];
88
+ for (const w of words) {
89
+ const p = /^--platform=(.*)$/i.exec(w);
90
+ if (p)
91
+ platform = p[1];
92
+ else if (!w.startsWith("--"))
93
+ rest.push(w);
94
+ }
95
+ const asIdx = rest.findIndex((w) => w.toUpperCase() === "AS");
96
+ stages.push({
97
+ from: ins,
98
+ image: rest[0] ?? "",
99
+ platform,
100
+ name: asIdx >= 0 ? rest[asIdx + 1]?.toLowerCase() : undefined,
101
+ body: [],
102
+ });
103
+ }
104
+ else if (stages.length > 0) {
105
+ stages[stages.length - 1].body.push(ins);
106
+ }
107
+ }
108
+ return stages;
109
+ }
110
+ /** The final stage plus any earlier stages it is built FROM, root first. */
111
+ function finalChain(stages) {
112
+ const chain = [];
113
+ let idx = stages.length - 1;
114
+ while (idx >= 0) {
115
+ const stage = stages[idx];
116
+ chain.unshift(stage);
117
+ const parent = stage.image.toLowerCase();
118
+ idx = stages.findIndex((s, j) => j < idx && s.name === parent);
119
+ }
120
+ return chain;
121
+ }
122
+ // ── Helpers ─────────────────────────────────────────────────────────────────
123
+ function parseExecForm(args) {
124
+ if (!args.startsWith("["))
125
+ return undefined;
126
+ try {
127
+ const parsed = JSON.parse(args);
128
+ return Array.isArray(parsed) && parsed.every((x) => typeof x === "string") ? parsed : undefined;
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ /** Parse `KEY=value KEY2="v w"` (or legacy `KEY value` for ENV) into pairs. */
135
+ function parseAssignments(keyword, args) {
136
+ const legacy = /^([A-Za-z_][A-Za-z0-9_]*)\s+(.*)$/s.exec(args);
137
+ if (keyword === "ENV" && legacy && !args.split(/\s+/)[0].includes("=")) {
138
+ return [{ name: legacy[1], value: unquote(legacy[2].trim()) }];
139
+ }
140
+ const pairs = [];
141
+ const re = /([A-Za-z_][A-Za-z0-9_]*)(?:=("(?:[^"\\]|\\.)*"|'[^']*'|\S*))?/g;
142
+ for (const m of args.matchAll(re))
143
+ pairs.push({ name: m[1], value: m[2] === undefined ? undefined : unquote(m[2]) });
144
+ return pairs;
145
+ }
146
+ function unquote(v) {
147
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
148
+ return v.slice(1, -1);
149
+ return v;
150
+ }
151
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|CREDENTIAL)/i;
152
+ const SECRET_VALUE = /^(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+)$/;
153
+ function isLiteral(value) {
154
+ return value !== undefined && value !== "" && !/^\$\{?[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}?$/.test(value);
155
+ }
156
+ function parseExpose(args) {
157
+ const out = [];
158
+ for (const word of args.split(/\s+/).filter(Boolean)) {
159
+ const m = /^(\d+)(?:-(\d+))?(?:\/(tcp|udp))?$/i.exec(word);
160
+ if (!m)
161
+ continue; // variables and other forms can't be resolved statically
162
+ out.push({ from: Number(m[1]), to: Number(m[2] ?? m[1]), protocol: (m[3]?.toLowerCase() ?? "tcp") });
163
+ }
164
+ return out;
165
+ }
166
+ function rule(code) {
167
+ return DOCKERFILE_RULES.find((r) => r.code === code);
168
+ }
169
+ // ── Check ───────────────────────────────────────────────────────────────────
170
+ export function checkDockerfile(text, opts = {}) {
171
+ const instructions = parseDockerfile(text);
172
+ const stages = splitStages(instructions);
173
+ if (instructions.length === 0 || stages.length === 0) {
174
+ throw new GuardError("No Dockerfile instructions found: pass the full Dockerfile text, starting with a FROM line.");
175
+ }
176
+ const findings = [];
177
+ const add = (code, line, message, fix) => findings.push({ severity: rule(code).severity, code, line, message, fix });
178
+ const chain = finalChain(stages);
179
+ const root = chain[0];
180
+ const finalFrom = chain[chain.length - 1].from;
181
+ const runtime = chain.flatMap((s) => s.body);
182
+ // GC1: linux/amd64 only.
183
+ for (const stage of chain) {
184
+ if (stage.platform && /arm64|aarch64|linux\/arm\b|arm\/v/i.test(stage.platform)) {
185
+ add("GC1-arm64-platform", stage.from.line, `The runtime image is pinned to ${stage.platform}; the Gameye fleet runs linux/amd64 only.`, `Remove the arm64 --platform pin (or use --platform=linux/amd64) and build with docker buildx build --platform=linux/amd64. See ${SKILL}.`);
186
+ }
187
+ }
188
+ if (/^mcr\.microsoft\.com\/windows|nanoserver|servercore/i.test(root.image) || /^windows/i.test(root.platform ?? "")) {
189
+ add("GC1-windows-base", root.from.line, "The runtime image uses a Windows base image; Gameye runs Linux containers only.", `Build a Linux dedicated server and use a Linux base image such as debian:bookworm-slim. See ${SKILL}.`);
190
+ }
191
+ // GC2: EXPOSE present and consistent with declared ports.
192
+ const exposes = runtime.filter((i) => i.keyword === "EXPOSE");
193
+ if (exposes.length === 0) {
194
+ add("GC2-expose-missing", finalFrom.line, "The runtime stage has no EXPOSE; the exposed ports must match what the server binds and the application's port bindings.", `Add EXPOSE <port>/<protocol> for every port the server binds, e.g. EXPOSE 7777/udp. See ${SKILL}.`);
195
+ }
196
+ else if (opts.ports && opts.ports.length > 0) {
197
+ const exposed = exposes.flatMap((i) => parseExpose(i.args).map((p) => ({ ...p, line: i.line })));
198
+ const lastExpose = exposes[exposes.length - 1].line;
199
+ const isExposed = (d) => exposed.some((e) => e.protocol === d.protocol && d.port >= e.from && d.port <= e.to);
200
+ for (const d of opts.ports) {
201
+ if (isExposed(d))
202
+ continue;
203
+ const other = exposed.find((e) => d.port >= e.from && d.port <= e.to);
204
+ add("GC2-port-mismatch", other?.line ?? lastExpose, other
205
+ ? `Port ${d.port} is declared as ${d.protocol} but exposed as ${other.protocol}.`
206
+ : `Declared port ${d.port}/${d.protocol} is not exposed.`, `Make EXPOSE list exactly the ports and protocols the server binds (${d.port}/${d.protocol}), and match them in the application's port bindings. See ${SKILL}.`);
207
+ }
208
+ for (const e of exposed) {
209
+ const declared = opts.ports.some((d) => d.port >= e.from && d.port <= e.to);
210
+ if (declared)
211
+ continue;
212
+ const range = e.from === e.to ? `${e.from}` : `${e.from}-${e.to}`;
213
+ add("GC2-port-mismatch", e.line, `Port ${range}/${e.protocol} is exposed but not among the declared ports.`, `Remove the EXPOSE for ports the server does not bind, or declare them. See ${SKILL}.`);
214
+ }
215
+ }
216
+ // GC2 / GC4: launch command.
217
+ const lastOf = (kw) => [...runtime].reverse().find((i) => i.keyword === kw);
218
+ const launch = [lastOf("ENTRYPOINT"), lastOf("CMD")].filter((i) => i !== undefined).sort((a, b) => a.line - b.line);
219
+ for (const ins of launch) {
220
+ const exec = parseExecForm(ins.args);
221
+ if (!exec) {
222
+ add("GC4-exec-form", ins.line, `${ins.keyword} uses shell form, so /bin/sh is PID 1 and swallows SIGTERM; the server is hard-killed with no drain.`, `Use exec form, e.g. ${ins.keyword} ["/server/game-server", "--port", "7777"], and exec the binary from any entrypoint script. See ${SKILL}.`);
223
+ }
224
+ const words = exec ?? ins.args.split(/\s+/);
225
+ const cmd = words.join(" ");
226
+ if (/(^|[\s/])(npm|yarn)(\s+run)?\s+start\b/.test(cmd)) {
227
+ add("GC4-npm-start", ins.line, `${ins.keyword} launches through npm/yarn start, which sits between PID 1 and the server and swallows SIGTERM.`, `Run the server directly, e.g. ${ins.keyword} ["node", "server/server.js"]. See ${SKILL}.`);
228
+ }
229
+ if (/(^|[^\d.])127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?![\d.])|\blocalhost\b/i.test(cmd)) {
230
+ add("GC2-loopback-bind", ins.line, `${ins.keyword} binds the server to a loopback address, which is unreachable from outside the container.`, `Bind on 0.0.0.0 (all interfaces) instead of 127.0.0.1 or localhost. See ${SKILL}.`);
231
+ }
232
+ }
233
+ // GC7: non-root user.
234
+ const user = lastOf("USER");
235
+ const userName = user?.args.split(/\s+/)[0].split(":")[0];
236
+ if (!user || userName === "root" || userName === "0") {
237
+ add("GC7-non-root", user?.line ?? finalFrom.line, user ? "The runtime stage switches to root." : "The runtime stage sets no USER, so the server runs as root unless the base image says otherwise.", `Create a system user and switch to it before ENTRYPOINT, e.g. USER game. See ${SKILL}.`);
238
+ }
239
+ // GC9: no Gameye API token or credential in ENV/ARG (any stage; the Dockerfile itself is shared).
240
+ const secrets = [];
241
+ for (const ins of instructions) {
242
+ if (ins.keyword !== "ENV" && ins.keyword !== "ARG")
243
+ continue;
244
+ for (const { name, value } of parseAssignments(ins.keyword, ins.args)) {
245
+ const gameye = name.toUpperCase() === "GAMEYE_API_TOKEN";
246
+ const literal = isLiteral(value);
247
+ const credential = gameye || (literal && (SECRET_NAME.test(name) || SECRET_VALUE.test(value)));
248
+ if (!credential)
249
+ continue;
250
+ if (literal)
251
+ secrets.push(value);
252
+ add("GC9-token-in-image", ins.line,
253
+ // Only the fixed GAMEYE_API_TOKEN name is echoed: any other name is user text and may itself hold a token.
254
+ `${ins.keyword} ${gameye ? `GAMEYE_API_TOKEN${value === undefined ? "" : `=${MASK}`}` : "on this line"} puts ${gameye ? "a Gameye API token" : "a credential"} in the image or its build history.`, `Remove it from the Dockerfile. The Gameye API token belongs to your backend only; pass only short-lived per-match values in the session env. Rotate the token if this was ever pushed. See ${SKILL}.`);
255
+ }
256
+ }
257
+ findings.sort((a, b) => a.line - b.line);
258
+ return { findings: redact(findings, secrets) };
259
+ }
260
+ /** Replace every flagged credential value with `***`, wherever it appears. */
261
+ function redact(findings, secrets) {
262
+ const values = [...new Set(secrets)].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length);
263
+ const clean = (t) => values.reduce((acc, v) => acc.split(v).join(MASK), t);
264
+ return findings.map((f) => ({ ...f, message: clean(f.message), fix: clean(f.fix) }));
265
+ }
266
+ export function formatDockerfileReport(result) {
267
+ const errors = result.findings.filter((f) => f.severity === "error").length;
268
+ const warnings = result.findings.length - errors;
269
+ const lines = [`Dockerfile check against the ${SKILL} container contract: ${errors} error(s), ${warnings} warning(s).`, ""];
270
+ if (result.findings.length === 0) {
271
+ lines.push("No contract issues found in the Dockerfile text.", "");
272
+ }
273
+ for (const f of result.findings) {
274
+ lines.push(`${f.severity.toUpperCase()} ${f.code} (line ${f.line}): ${f.message}`, ` Fix: ${f.fix}`, "");
275
+ }
276
+ lines.push(CANNOT_KNOW);
277
+ return lines.join("\n");
278
+ }
package/dist/env.d.ts CHANGED
@@ -12,19 +12,39 @@ export interface ResolvedConfig {
12
12
  baseUrl: string;
13
13
  /** Hostname only, for display. */
14
14
  host: string;
15
- /** API token. Never print or return this. */
16
- token: string;
15
+ /** API token, absent in no-token mode. Never print or return this. */
16
+ token?: string;
17
+ /** True when a usable token is configured. Derived from `token`; safe to show. */
18
+ hasToken: boolean;
17
19
  /** Default per-request timeout in milliseconds (GAMEYE_TIMEOUT_MS, default 30s). */
18
20
  timeoutMs: number;
21
+ /** Local launch and stop limits (GAMEYE_MAX_SESSIONS, GAMEYE_MAX_TTL, GAMEYE_MAX_LAUNCHES, GAMEYE_STOP_OWN_ONLY). */
22
+ limits: SessionLimits;
19
23
  }
24
+ /** Limits on what an agent can start through one server process. See limits.ts. */
25
+ export interface SessionLimits {
26
+ /** Sessions started by this process that may run at once. */
27
+ maxSessions: number;
28
+ /** Longest TTL a launch may ask for, as given (e.g. `4h`). */
29
+ maxTtl: string;
30
+ maxTtlMs: number;
31
+ /** Launches per server process. */
32
+ maxLaunches: number;
33
+ /** Stop only sessions this process started. */
34
+ stopOwnOnly: boolean;
35
+ }
36
+ export declare const DEFAULT_LIMITS: SessionLimits;
20
37
  /** Per-request timeout used when GAMEYE_TIMEOUT_MS is not set. */
21
38
  export declare const DEFAULT_TIMEOUT_MS = 30000;
22
39
  export declare class ConfigError extends Error {
23
40
  constructor(message: string);
24
41
  }
42
+ /** The one stderr line printed at start in no-token mode. Never mentions a token value. */
43
+ export declare const NO_TOKEN_NOTICE = "No GAMEYE_API_TOKEN set: account tools will refuse until a token is set and the server is restarted.";
25
44
  /**
26
45
  * Resolve the server configuration from an environment object.
27
46
  * Pure: throws ConfigError on any invalid combination and never touches process state.
47
+ * A missing token is not an error: it resolves with `hasToken: false`.
28
48
  */
29
49
  export declare function resolveConfig(env: Record<string, string | undefined>): ResolvedConfig;
30
50
  /** Resolve from process.env, printing a clear message and exiting 1 on any error. */
@@ -33,4 +53,5 @@ export declare function loadConfigOrExit(env?: Record<string, string | undefined
33
53
  export declare function resultPrefix(config: {
34
54
  environment: EnvironmentLabel;
35
55
  host: string;
56
+ hasToken?: boolean;
36
57
  }): string;