diff --git a/workspace-server/internal/handlers/org.go b/workspace-server/internal/handlers/org.go index 8b5c4585..631596e3 100644 --- a/workspace-server/internal/handlers/org.go +++ b/workspace-server/internal/handlers/org.go @@ -392,6 +392,15 @@ type OrgDefaults struct { // InitialMemories are default memories seeded into every workspace at // creation time unless the workspace overrides them. Issue #1050. InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"` + // RequiredEnv / RecommendedEnv at the defaults level carry the + // runtime's own env requirements (e.g. ANTHROPIC_API_KEY for + // claude-code). They are injected into tmpl.RequiredEnv / + // tmpl.RecommendedEnv during Import so collectOrgEnv picks them up + // without needing an explicit org-level declaration. The same values + // also flow into each workspace's config.yaml at provision time via + // ensureDefaultConfig in workspace_provision.go. + RequiredEnv []EnvRequirement `yaml:"required_env" json:"required_env"` + RecommendedEnv []EnvRequirement `yaml:"recommended_env" json:"recommended_env"` } type OrgSchedule struct { @@ -638,6 +647,21 @@ func (h *OrgHandler) Import(c *gin.Context) { return } + // Inject runtime-required env from defaults into the template-level + // union. Each runtime declares its own required_env (e.g. + // ANTHROPIC_API_KEY for claude-code); without this injection, + // collectOrgEnv only sees what's explicitly declared in + // tmpl.RequiredEnv / OrgWorkspace.RequiredEnv and silently misses + // the runtime's needs. The canvas's preflight modal passes a + // correctly-populated tmpl via the `template` body path, so only + // the `dir` (template-on-disk) path needs this injection. Issue #232. + if len(tmpl.Defaults.RequiredEnv) > 0 { + tmpl.RequiredEnv = append(tmpl.RequiredEnv, tmpl.Defaults.RequiredEnv...) + } + if len(tmpl.Defaults.RecommendedEnv) > 0 { + tmpl.RecommendedEnv = append(tmpl.RecommendedEnv, tmpl.Defaults.RecommendedEnv...) + } + // Emit started AFTER the YAML is loaded so payload.name carries the // resolved template name (was: empty when caller passed `dir` instead // of inline `template`). Pre-parse error paths above return without diff --git a/workspace-server/internal/handlers/org_test.go b/workspace-server/internal/handlers/org_test.go index 19dbece9..9fce233b 100644 --- a/workspace-server/internal/handlers/org_test.go +++ b/workspace-server/internal/handlers/org_test.go @@ -1076,3 +1076,94 @@ func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) { t.Errorf("expected VALID_ONE to survive, got %v", reqNames(req)) } } + +// TestCollectOrgEnv_DefaultsRequiredEnv verifies that runtime-required_env +// declared at the defaults level is picked up by collectOrgEnv, matching the +// injection that Import performs from Defaults.RequiredEnv into tmpl.RequiredEnv +// before calling collectOrgEnv. Without this injection, a template with +// defaults.runtime=claude-code and no explicit RequiredEnv would silently skip +// ANTHROPIC_API_KEY, causing the import preflight to pass while every workspace +// fails on first LLM call. Issue #232. +func TestCollectOrgEnv_DefaultsRequiredEnv(t *testing.T) { + tmpl := &OrgTemplate{ + Defaults: OrgDefaults{ + Runtime: "claude-code", + RequiredEnv: strictReq("ANTHROPIC_API_KEY"), + RecommendedEnv: strictReq("SERPER_API_KEY"), + }, + Workspaces: []OrgWorkspace{ + { + Name: "Worker", + RequiredEnv: strictReq("GITHUB_TOKEN"), + Children: []OrgWorkspace{ + { + Name: "Leaf", + }, + }, + }, + }, + } + req, rec := collectOrgEnv(tmpl) + // Required is the union: ANTHROPIC_API_KEY (from defaults) + GITHUB_TOKEN (from Worker). + wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN"} + if !stringSlicesEqual(reqNames(req), wantReq) { + t.Errorf("required mismatch: got %v, want %v", reqNames(req), wantReq) + } + wantRec := []string{"SERPER_API_KEY"} + if !stringSlicesEqual(reqNames(rec), wantRec) { + t.Errorf("recommended mismatch: got %v, want %v", reqNames(rec), wantRec) + } +} + +// TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel tests that +// when BOTH defaults.RequiredEnv AND an explicit org-level RequiredEnv declare +// the same key, collectOrgEnv deduplicates and keeps only one entry. +func TestCollectOrgEnv_DefaultsRequiredEnv_DedupWithExplicitOrgLevel(t *testing.T) { + tmpl := &OrgTemplate{ + RequiredEnv: strictReq("ANTHROPIC_API_KEY"), + Defaults: OrgDefaults{ + Runtime: "claude-code", + RequiredEnv: strictReq("ANTHROPIC_API_KEY"), + RecommendedEnv: strictReq("SERPER_API_KEY"), + }, + Workspaces: []OrgWorkspace{ + { + Name: "Root", + }, + }, + } + req, rec := collectOrgEnv(tmpl) + wantReq := []string{"ANTHROPIC_API_KEY"} + if !stringSlicesEqual(reqNames(req), wantReq) { + t.Errorf("required should dedupe: got %v, want %v", reqNames(req), wantReq) + } + if !stringSlicesEqual(reqNames(rec), []string{"SERPER_API_KEY"}) { + t.Errorf("recommended: got %v, want [SERPER_API_KEY]", reqNames(rec)) + } +} + +// TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults tests that any-of +// groups from defaults.RequiredEnv are preserved and flow into the union. +func TestCollectOrgEnv_DefaultsRequiredEnv_AnyOfFromDefaults(t *testing.T) { + tmpl := &OrgTemplate{ + Defaults: OrgDefaults{ + Runtime: "claude-code", + RequiredEnv: []EnvRequirement{ + anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"), + }, + }, + } + req, _ := collectOrgEnv(tmpl) + if len(req) != 1 { + t.Fatalf("expected 1 requirement, got %d: %v", len(req), reqNames(req)) + } + if req[0].Name != "" { + t.Errorf("expected any-of group from defaults.RequiredEnv, got strict name %q", req[0].Name) + } + want := []string{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"} + got := append([]string(nil), req[0].AnyOf...) + sort.Strings(got) + if !stringSlicesEqual(got, want) { + t.Errorf("any-of mismatch: got %v, want %v", got, want) + } +}