Compare commits

...

1 Commits

Author SHA1 Message Date
core-qa e0178b04c6 fix(org-import): aggregate defaults.RequiredEnv into preflight check (issue #232)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Failing after 10s
audit-force-merge / audit (pull_request) Has been skipped
When importing an org template via `dir` path, the Import handler read the
YAML and called collectOrgEnv — but Defaults.RequiredEnv (carrying the
runtime's own env requirements, e.g. ANTHROPIC_API_KEY for claude-code)
was not in tmpl.RequiredEnv, so collectOrgEnv silently skipped it.
The canvas preflight modal bypasses this by passing a fully-populated
tmpl via the `template` body field, so the bug only manifests in the
`dir` (template-on-disk) import path.

Fix:
- Add RequiredEnv / RecommendedEnv fields to OrgDefaults struct (same
  shape as OrgTemplate.RequiredEnv). Templates on disk declare
  defaults.required_env at the defaults level; the import handler now
  injects these into tmpl.RequiredEnv / tmpl.RecommendedEnv before
  calling collectOrgEnv, so the preflight sees the runtime's requirements.
- collectOrgEnv is unchanged — it already walks the tree correctly. The
  injection point is in Import, not collectOrgEnv, so the existing test
  coverage is preserved.
- 3 new regression tests prove: (1) defaults.RequiredEnv flows into
  the union, (2) duplicate key with explicit org-level RequiredEnv
  deduplicates correctly, (3) any-of groups from defaults survive.

Repro scenario fixed: importing molecule-dev (38 workspaces, runtime=claude-
code, no explicit org-level RequiredEnv) now correctly blocks on
ANTHROPIC_API_KEY and shows the MissingKeysModal instead of silently
creating 38 NOT CONFIGURED workspaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 07:01:28 +00:00
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)
}
}