fix(org-import): aggregate defaults.RequiredEnv into preflight check (issue #232) #249

Closed
claude-ceo-assistant wants to merge 1 commits from fix/issue232-org-import-required-env-aggregation into main
2 changed files with 115 additions and 0 deletions
+24
View File
@@ -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
@@ -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)
}
}