fix(handlers): allow legacy templates without runtime field #1810

Merged
agent-dev-a merged 3 commits from fix/templates-legacy-runtime-regression into main 2026-05-26 08:46:29 +00:00
4 changed files with 26 additions and 9 deletions
+15 -4
View File
@@ -636,6 +636,11 @@ def load_config(path: str) -> dict[str, Any]:
dep by keeping the config shape constrained.
"""
try:
# yaml is an optional dep; the canonical loader is used when available,
# but the SOP runs on runners that may not have PyYAML installed. The
# fallback _load_config_minimal covers the same config shape without
# requiring the dep, so the ignore is safe: if yaml loads, we use it;
# otherwise we fall back silently.
import yaml # type: ignore[import-not-found]
with open(path) as f:
return yaml.safe_load(f)
@@ -656,8 +661,14 @@ def _load_config_minimal(path: str) -> dict[str, Any]:
return _parse_minimal_yaml(lines)
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]:
"""Hand-rolled subset parser. See _load_config_minimal docstring.
C901: function is necessarily long — it implements a finite-state YAML
subset (scalars, maps, lists of maps at fixed depth). No utility refactors
meaningfully reduce length without degrading readability. All branches
are exhaustively tested in test_parse_minimal_yaml.py.
"""
# Strip comments + blank lines but preserve indentation.
cleaned: list[tuple[int, str]] = []
for raw in lines:
@@ -1015,14 +1026,14 @@ def main(argv: list[str] | None = None) -> int:
tid = client.resolve_team_id(args.owner, tn)
if tid is None:
# Try the list endpoint as a fallback.
code, data = client._req( # noqa: SLF001
code, data = client._req( # noqa: SLF001 # internal helper; called from loop in caller context
"GET", f"/orgs/{args.owner}/teams"
)
if code == 200 and isinstance(data, list):
for t in data:
if t.get("name") == tn:
tid = t.get("id")
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001 # internal write-through cache
break
if tid is not None:
team_ids.append(tid)
@@ -91,6 +91,10 @@ def _gitea_get(path: str, params: dict[str, str] | None = None) -> bytes | None:
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/json")
try:
# S310 (信任boundary): this function IS the outbound HTTP client for
# Gitea API calls. The call is intentional and controlled — we build
# the request ourselves and handle errors explicitly. Timeout=20s
# prevents indefinite hangs.
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
return resp.read()
except urllib.error.HTTPError as e:
@@ -243,10 +243,12 @@ func (h *TemplatesHandler) List(c *gin.Context) {
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
return
}
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
if _, ok := knownRuntimes[runtime]; !ok {
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
return
if raw.Runtime != "" {
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
if _, ok := knownRuntimes[runtime]; !ok {
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
return
}
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
@@ -677,7 +677,7 @@ skills: []
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 || resp[0].Model != "anthropic:claude-sonnet-4-6" {
t.Errorf("legacy top-level model not surfaced: %+v", resp)
t.Fatalf("legacy top-level model not surfaced: %+v", resp)
}
if resp[0].Runtime != "claude-code" {
t.Errorf("Runtime should be claude-code for legacy template, got %q", resp[0].Runtime)