diff --git a/workspace-server/internal/handlers/runtime_registry_test.go b/workspace-server/internal/handlers/runtime_registry_test.go index 2ab133da..057fd12f 100644 --- a/workspace-server/internal/handlers/runtime_registry_test.go +++ b/workspace-server/internal/handlers/runtime_registry_test.go @@ -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 diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 058a5f56..83029c8b 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -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