Launch options must be documented. A draft passed an option its server doesn't have; servers ignore unknown options, so it booted and passed every boot test while its setting did nothing. build now reads the pages research based the variant on, in full, and the drafter rejects any launch option in the startup command that none of them, nor the image's own scripts, mention. Shell tools' options (mkdir -p, [ -n ...]) are skipped; the check is off without sources. The builder is game-agnostic: - AUTHORING.md (the model's rules), the research prompt and the schema describe the rules without naming a game. - The draft prompt no longer includes real templates from the repo. Its format example is one invented server (no real command, options, ports or app id), checked by TestExampleTemplateFollowsTheRules. - Comments, help texts, BUILDER.md and test fixtures use invented names and values. - TestBuilderIsGameAgnostic reads every game's name and id from games/ and fails if any appears in the builder's code, tests, rules, schemas or docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Checks a booting draft can still fail: things that only go wrong once an owner changes a setting, so
|
|
// neither the validator nor a boot test with the defaults sees them.
|
|
|
|
// splitSettings finds text settings that reach the server's command line unquoted, so a value with spaces
|
|
// ("GBRHosting - My Server") arrives as several arguments and the game only sees the first word. Covers
|
|
// direct use (-name ${SERVER_NAME}) and the common ARGS="-name ${SERVER_NAME}"; ./server $ARGS, where the
|
|
// quotes in the assignment don't survive the unquoted $ARGS.
|
|
func splitSettings(game map[string]any) []string {
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
if startup == "" {
|
|
return nil
|
|
}
|
|
textVars := map[string]string{} // env name -> setting key
|
|
for key, p := range schemaProperties(game) {
|
|
if prop, _ := p.(map[string]any); prop["type"] == "string" {
|
|
textVars[envName(key)] = key
|
|
}
|
|
}
|
|
if len(textVars) == 0 {
|
|
return nil
|
|
}
|
|
s := scanShell(unwrapShell(startup))
|
|
|
|
split := map[string]string{} // setting key -> how it gets split
|
|
var reach func(holder string, seen map[string]bool) []string
|
|
reach = func(holder string, seen map[string]bool) []string {
|
|
if seen[holder] {
|
|
return nil
|
|
}
|
|
seen[holder] = true
|
|
var out []string
|
|
for _, v := range s.held[holder] {
|
|
if _, ok := textVars[v]; ok {
|
|
out = append(out, v)
|
|
}
|
|
out = append(out, reach(v, seen)...)
|
|
}
|
|
return out
|
|
}
|
|
for _, e := range s.uses {
|
|
if e.quoted {
|
|
continue
|
|
}
|
|
if key, ok := textVars[e.name]; ok {
|
|
split[key] = "${" + e.name + "} is not in double quotes"
|
|
}
|
|
if len(s.held[e.name]) > 0 {
|
|
for _, v := range reach(e.name, map[string]bool{}) {
|
|
if _, done := split[textVars[v]]; !done {
|
|
split[textVars[v]] = fmt.Sprintf("${%s} is inside $%s, which is used without double quotes", v, e.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var problems []string
|
|
for _, key := range sortedKeysString(split) {
|
|
problems = append(problems, fmt.Sprintf("setting %s is split into words on the command line (%s): a value with spaces like "+
|
|
"\"GBRHosting - My Server\" reaches the game as several arguments. Quote it where it is used (-name \"${%s}\"), or build "+
|
|
"optional arguments in a bash array: args=(-name \"$%s\"); [ -n \"$X\" ] && args+=(-x \"$X\"); ./server \"${args[@]}\"",
|
|
key, split[key], envName(key), envName(key)))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`)
|
|
|
|
// unwrapShell looks inside a startup command that is one bash -c '...', which would otherwise be
|
|
// all single-quoted text.
|
|
func unwrapShell(cmd string) string {
|
|
if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") {
|
|
return m[1]
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
type shellUse struct {
|
|
name string
|
|
quoted bool
|
|
}
|
|
|
|
type shellScan struct {
|
|
uses []shellUse // expansions where the value is used: words, arguments
|
|
held map[string][]string // variable -> variables its assigned value contains
|
|
}
|
|
|
|
var (
|
|
assignWord = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\+?=`)
|
|
varName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
|
|
nestedVar = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`)
|
|
)
|
|
|
|
// scanShell is just enough of a shell parser for splitSettings: quotes, $VAR / ${VAR...} expansions, and
|
|
// assignments (NAME=..., NAME+=..., NAME=(...) arrays).
|
|
func scanShell(cmd string) *shellScan {
|
|
s := &shellScan{held: map[string][]string{}}
|
|
var single, double, wordStart = false, false, true
|
|
assign, inArray := "", false
|
|
record := func(name string, quoted bool) {
|
|
switch {
|
|
case assign != "" && !inArray:
|
|
// NAME="...$X..." : no splitting yet, but $NAME carries X along
|
|
s.held[assign] = append(s.held[assign], name)
|
|
case inArray && quoted:
|
|
s.held[assign] = append(s.held[assign], name) // safe unless the array is expanded unquoted
|
|
default:
|
|
s.uses = append(s.uses, shellUse{name, quoted})
|
|
}
|
|
}
|
|
for i := 0; i < len(cmd); i++ {
|
|
c := cmd[i]
|
|
if wordStart && !single && !double {
|
|
if m := assignWord.FindString(cmd[i:]); m != "" {
|
|
assign = strings.TrimRight(strings.TrimSuffix(m, "="), "+")
|
|
i += len(m)
|
|
if i < len(cmd) && cmd[i] == '(' {
|
|
inArray = true
|
|
} else {
|
|
i--
|
|
}
|
|
wordStart = false
|
|
continue
|
|
}
|
|
}
|
|
wordStart = false
|
|
switch {
|
|
case c == '\\' && !single:
|
|
i++
|
|
case c == '\'' && !double:
|
|
single = !single
|
|
case c == '"' && !single:
|
|
double = !double
|
|
case c == '$' && !single && i+1 < len(cmd):
|
|
if cmd[i+1] == '{' {
|
|
end := matchingBrace(cmd, i+1)
|
|
body := cmd[i+2 : end]
|
|
name := varName.FindString(strings.TrimLeft(body, "#!"))
|
|
if name != "" {
|
|
record(name, double)
|
|
}
|
|
// Defaults can hold more expansions: ${A:-${B}}
|
|
for _, m := range nestedVar.FindAllStringSubmatch(body[len(name):], -1) {
|
|
record(m[1], double)
|
|
}
|
|
i = end
|
|
} else if name := varName.FindString(cmd[i+1:]); name != "" {
|
|
record(name, double)
|
|
i += len(name)
|
|
}
|
|
case single || double:
|
|
case inArray && c == ')':
|
|
inArray, assign = false, ""
|
|
case inArray:
|
|
case c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
|
|
assign, wordStart = "", true
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func matchingBrace(s string, open int) int {
|
|
depth := 0
|
|
for i := open; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return len(s) - 1
|
|
}
|
|
|
|
var (
|
|
portSetting = regexp.MustCompile(`(?i)(port$|(^|_)port(_|$))`)
|
|
notAPort = regexp.MustCompile(`(?i)(sup|re|ex|im|trans)port$`)
|
|
// Reached over the Docker network, never published (AUTHORING.md)
|
|
internalPort = regexp.MustCompile(`(?i)rcon|rest|api|admin|telnet|web|http|metrics`)
|
|
)
|
|
|
|
// portSettings checks settings that set a port players connect to against defaultPorts. The node always
|
|
// publishes defaultPorts' container ports (moving only the host side when servers share a node), so a
|
|
// game listening anywhere else can't be reached: a default that isn't published is broken from the
|
|
// start, and an editable one breaks the moment an owner changes it.
|
|
func portSettings(game map[string]any) []string {
|
|
published := map[int]bool{}
|
|
ports, _ := game["defaultPorts"].([]any)
|
|
for _, p := range ports {
|
|
n, _ := strconv.Atoi(strings.Split(containerPort(fmt.Sprint(p)), "/")[0])
|
|
published[n] = true
|
|
}
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
props := schemaProperties(game)
|
|
var problems []string
|
|
keys := make([]string, 0, len(defaults))
|
|
for k := range defaults {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if !portSetting.MatchString(key) || notAPort.MatchString(key) || internalPort.MatchString(key) {
|
|
continue
|
|
}
|
|
port, ok := portNumber(defaults[key])
|
|
if !ok {
|
|
continue // {PUBLIC_PORT_n} and other placeholders are filled per server
|
|
}
|
|
_, editable := props[key]
|
|
switch {
|
|
case !published[port]:
|
|
problems = append(problems, fmt.Sprintf("setting %s defaults to port %d, which defaultPorts does not publish: players can't reach it. "+
|
|
"Use one of the published ports, or add this port to defaultPorts if players connect to it", key, port))
|
|
case editable:
|
|
problems = append(problems, fmt.Sprintf("setting %s lets owners change the port, but the node always publishes container port %d "+
|
|
"(defaultPorts), so any other value makes the server unreachable. Don't make it a setting: remove %s from "+
|
|
"defaultConfig and configSchema and write %d directly in the startup command or config file. If the game must "+
|
|
"announce the port players use, give it {PUBLIC_PORT_%d} instead", key, port, key, port, port))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func portNumber(v any) (int, bool) {
|
|
var s string
|
|
switch n := v.(type) {
|
|
case json.Number:
|
|
s = n.String()
|
|
case string:
|
|
s = n
|
|
case int, int64, float64:
|
|
s = fmt.Sprint(n)
|
|
default:
|
|
return 0, false
|
|
}
|
|
p, err := strconv.Atoi(strings.TrimSpace(s))
|
|
return p, err == nil && p > 0 && p < 65536
|
|
}
|
|
|
|
var (
|
|
optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`)
|
|
segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`)
|
|
testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true}
|
|
// Options of these are the shell's and its tools', not the game's
|
|
shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true,
|
|
"wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true,
|
|
"sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true,
|
|
"cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true}
|
|
)
|
|
|
|
// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor
|
|
// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one
|
|
// boots fine and passes every boot test while its setting does nothing.
|
|
func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string {
|
|
if strings.TrimSpace(docs) == "" {
|
|
return nil
|
|
}
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
corpus := docs
|
|
if facts != nil {
|
|
for _, s := range facts.Scripts {
|
|
corpus += "\n" + s
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
var missing []string
|
|
for _, opt := range launchOptions(unwrapShell(startup)) {
|
|
name := strings.TrimLeft(opt, "-")
|
|
key := strings.ToLower(name)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`)
|
|
if !documented.MatchString(corpus) {
|
|
missing = append(missing, opt)
|
|
}
|
|
}
|
|
if len(missing) == 0 {
|
|
return nil
|
|
}
|
|
return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+
|
|
"Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+
|
|
"only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))}
|
|
}
|
|
|
|
// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]).
|
|
func launchOptions(cmd string) []string {
|
|
var out []string
|
|
for _, seg := range segmentSep.Split(cmd, -1) {
|
|
fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({"))
|
|
i := 0
|
|
for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" ||
|
|
(assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) {
|
|
i++
|
|
}
|
|
if i == len(fields) {
|
|
continue
|
|
}
|
|
if word := path.Base(fields[i]); shellTools[word] {
|
|
continue
|
|
}
|
|
for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) {
|
|
name := strings.TrimLeft(m[1], "-")
|
|
if len(name) < 2 || testOps[name] {
|
|
continue
|
|
}
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
return out
|
|
}
|