fix(provision): resolve template-asset fetch by TEMPLATE not runtime (fixes seo-agent skills, #32) #2991

Merged
core-devops merged 1 commits from fix/template-asset-fetch-by-template-not-runtime into main 2026-06-16 15:27:44 +00:00
2 changed files with 56 additions and 1 deletions
@@ -242,6 +242,40 @@ func TestTemplateIdentityForRuntimeOrEmpty(t *testing.T) {
}
}
// TestTemplateIdentityForTemplateOrRuntime is the #32 regression gate: a
// template VARIANT (seo-agent, runtime=claude-code) must resolve its fetch
// identity from the TEMPLATE (seo-agent), not the runtime (claude-code).
// Before the fix the fetch keyed on runtime → resolved the claude-code-default
// template → delivered NONE of seo-agent's agent-skills/seo-all. This asserts
// the variant resolves to its own repo, falls back to runtime when no template,
// and stays empty for external runtimes.
func TestTemplateIdentityForTemplateOrRuntime(t *testing.T) {
if manifestPath() == "" {
t.Skip("manifest.json not discoverable from this test cwd")
}
initTemplateRepoByName()
// VARIANT: template=seo-agent + runtime=claude-code must resolve to the
// SEO-AGENT repo, NOT the claude-code template. THIS is the regression.
seo := templateIdentityForTemplateOrRuntime("seo-agent", "claude-code")
if seo == "" || !strings.Contains(seo, "seo-agent") {
t.Errorf("seo-agent variant must resolve to the seo-agent template identity; got %q", seo)
}
cc := templateIdentityForTemplateOrRuntime("", "claude-code")
if seo == cc {
t.Errorf("seo-agent variant resolved to the SAME identity as claude-code (%q) — the fetch is keying on runtime, not template (#32 regression)", seo)
}
// FALLBACK: no template → use the runtime (runtime==template-name case).
if got := templateIdentityForTemplateOrRuntime("", "hermes"); got == "" {
t.Error("empty template should fall back to the runtime (hermes) identity")
}
// Unknown template falls back to runtime, then to "".
if got := templateIdentityForTemplateOrRuntime("no-such-template", "external"); got != "" {
t.Errorf("unknown template + external runtime should be empty, got %q", got)
}
}
// TestInitTemplateRepoByName_PopulatesMap_FromTempManifest pins the
// PR-B contract-pin: the prod-init path must populate templateRepoByName
// from a real manifest so cfg.TemplateIdentity is non-empty for
@@ -394,7 +394,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
// not duplicated across first-provision + restart paths.
// nil fetcher = "no fetcher wired" (self-host default;
// falls through to the local TemplatePath path).
TemplateIdentity: templateIdentityForRuntimeOrEmpty(payload.Runtime),
TemplateIdentity: templateIdentityForTemplateOrRuntime(payload.Template, payload.Runtime),
TemplateAssetFetcher: h.giteaTemplateFetcher,
}
}
@@ -408,6 +408,27 @@ func templateIdentityForRuntimeOrEmpty(runtime string) string {
return id
}
// templateIdentityForTemplateOrRuntime resolves the template-asset fetch
// identity, preferring the explicit TEMPLATE over the runtime. The manifest's
// workspace_templates (templateRepoByName) are keyed by TEMPLATE NAME
// (claude-code-default, seo-agent, platform-agent, …), NOT by runtime. A
// template VARIANT like seo-agent has runtime="claude-code" but
// template="seo-agent"; keying the fetch on runtime looked up
// templateRepoByName["claude-code"] (no such key) → empty identity → the
// fetcher delivered NOTHING, so agent-skills/seo-all never reached the box
// (config.yaml + prompts arrived via the legacy SM path, masking it). #32.
// Falls back to runtime for the common case where runtime==template name
// (hermes/codex/openclaw/google-adk), and to "" when neither resolves (external
// runtimes — collectCPConfigFiles treats empty identity as "skip the fetcher").
func templateIdentityForTemplateOrRuntime(template, runtime string) string {
if t := strings.TrimSpace(template); t != "" {
if id, ok := templateIdentityForRuntime(t); ok {
return id
}
}
return templateIdentityForRuntimeOrEmpty(runtime)
}
// issueAndInjectToken rotates the workspace auth token and injects the
// plaintext into cfg.ConfigFiles[".auth_token"] so it is written into the
// /configs volume by WriteFilesToContainer immediately after the container