feat(env-preflight): support any_of OR groups (e.g. API_KEY OR OAUTH_TOKEN)

Extends the org-import env preflight so a template can declare an
alternative: satisfy ANY one member to pass. Motivated by the
Claude-family node case where either ANTHROPIC_API_KEY or
CLAUDE_CODE_OAUTH_TOKEN unlocks the agent — forcing both was wrong.

Server (workspace-server):
  - New EnvRequirement union type with custom YAML + JSON
    (un)marshaling. Accepts scalar (strict) or {any_of: [...]} in
    both on-disk org.yaml and inline POST /org/import bodies.
  - collectOrgEnv now returns []EnvRequirement. Dedups groups by
    sorted-member signature. "Strict wins" pruning drops any-of
    groups that mention a name already declared strictly (same
    tier and cross-tier).
  - Import preflight uses EnvRequirement.IsSatisfied — scalar =
    exact match, group = any member present.
  - Empty any_of: [] rejected at parse time (never-satisfiable).
  - 14 handler tests (6 updated for the union shape, 8 new
    covering any-of satisfaction, dedup, strict-dominates-group,
    cross-tier pruning, invalid-member filtering, YAML round-trip,
    and empty-any-of rejection).

Canvas:
  - EnvRequirement = string | {any_of: string[]} with envReqMembers,
    envReqSatisfied, envReqKey helpers.
  - OrgImportPreflightModal renders strict rows and any-of groups
    via a new AnyOfEnvGroup sub-component: "Configure any one"
    banner, per-member input, ✓-satisfied indicator, and dimmed
    siblings once any member is configured so the user can still
    switch providers.
  - TemplatePalette.OrgTemplate.required_env / recommended_env
    retyped to EnvRequirement[]; passthrough to the modal
    unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-24 16:16:09 -07:00
parent f995b90a85
commit ad73a56db1
5 changed files with 728 additions and 113 deletions

View File

@ -3,6 +3,40 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { createSecret } from "@/lib/api/secrets";
/**
* One entry from the server's preflight `required_env` / `recommended_env`.
*
* - A plain string is a STRICT requirement: that exact env var must be
* configured.
* - A `{any_of: [...]}` object is an OR group: at least one member
* must be configured to satisfy it. Lets a template say "either
* ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" without forcing
* both.
*
* Matches the Go `EnvRequirement` type's JSON shape (MarshalJSON in
* workspace-server/internal/handlers/org.go). The union is written so
* that a narrow check `typeof e === "string"` distinguishes cleanly.
*/
export type EnvRequirement = string | { any_of: string[] };
/** Flat member list for a requirement. */
export function envReqMembers(r: EnvRequirement): string[] {
return typeof r === "string" ? [r] : r.any_of;
}
/** True if any member is present in `configured`. */
export function envReqSatisfied(r: EnvRequirement, configured: Set<string>): boolean {
if (typeof r === "string") return configured.has(r);
return r.any_of.some((m) => configured.has(m));
}
/** Stable react-key / dedup key for a requirement. Sorted for groups so
* reordered-member variants still collapse to one entry. */
export function envReqKey(r: EnvRequirement): string {
if (typeof r === "string") return r;
return [...r.any_of].sort().join("|");
}
interface Props {
open: boolean;
/** Display name of the org template — headline only. */
@ -10,11 +44,13 @@ interface Props {
/** Total workspace count so the header can read "12 workspaces". */
workspaceCount: number;
/** Env vars the server has declared MUST be set as global secrets.
* Import is disabled until every entry here is configured. */
requiredEnv: string[];
* Import is disabled until every entry here is configured. Entries
* are either a single key name or an any-of group. */
requiredEnv: EnvRequirement[];
/** Env vars the server suggests import can proceed without them,
* but the user sees them listed so they can decide. */
recommendedEnv: string[];
* but the user sees them listed so they can decide. Same union
* shape as `requiredEnv`. */
recommendedEnv: EnvRequirement[];
/** Names of env vars already configured globally. Used to strike
* through entries the user has already set up in another
* session. Passed in rather than queried inside the modal so the
@ -69,11 +105,23 @@ export function OrgImportPreflightModal({
}: Props) {
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
// Flatten the union-shaped requirement lists to the set of every key
// that could ever appear as an input row. Used purely to seed the
// drafts map — satisfaction semantics still read from the grouped
// EnvRequirement entries (a group can be satisfied by any one
// member).
const allMemberKeys = useMemo(() => {
const keys: string[] = [];
for (const r of requiredEnv) keys.push(...envReqMembers(r));
for (const r of recommendedEnv) keys.push(...envReqMembers(r));
return keys;
}, [requiredEnv, recommendedEnv]);
// Seed a draft entry per declared key the first time the modal
// opens. Entries persist across `configuredKeys` changes so a mid-
// save recheck doesn't wipe what the user typed.
//
// Dep: dervie a STABLE string from the env-name lists rather than
// Dep: derive a STABLE string from the env-name lists rather than
// the array refs themselves. The parent computes
// `preflight.org.required_env ?? []`, which produces a fresh []
// identity on every re-render (e.g. when refreshConfiguredKeys
@ -81,14 +129,14 @@ export function OrgImportPreflightModal({
// effect on every parent render and mask any future edit that
// drops the `if (!next[k])` guard as a silent input-reset bug.
const envKeysSignature = useMemo(
() => [...requiredEnv, ...recommendedEnv].sort().join("|"),
[requiredEnv, recommendedEnv],
() => [...allMemberKeys].sort().join("|"),
[allMemberKeys],
);
useEffect(() => {
if (!open) return;
setDrafts((prev) => {
const next = { ...prev };
for (const k of [...requiredEnv, ...recommendedEnv]) {
for (const k of allMemberKeys) {
if (!next[k]) {
next[k] = { key: k, value: "", saving: false, error: null };
}
@ -99,11 +147,11 @@ export function OrgImportPreflightModal({
}, [open, envKeysSignature]);
const missingRequired = useMemo(
() => requiredEnv.filter((k) => !configuredKeys.has(k)),
() => requiredEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
[requiredEnv, configuredKeys],
);
const missingRecommended = useMemo(
() => recommendedEnv.filter((k) => !configuredKeys.has(k)),
() => recommendedEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
[recommendedEnv, configuredKeys],
);
const canProceed = missingRequired.length === 0;
@ -240,7 +288,7 @@ interface EnvListProps {
tone: "required" | "recommended";
title: string;
subtitle: string;
entries: string[];
entries: EnvRequirement[];
configuredKeys: Set<string>;
drafts: Record<string, DraftEntry>;
onChange: (key: string, value: string) => void;
@ -271,35 +319,162 @@ function EnvList({
</h3>
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
<ul className="space-y-2">
{entries.map((k) => {
const configured = configuredKeys.has(k);
const d = drafts[k];
{entries.map((entry) =>
typeof entry === "string" ? (
<StrictEnvRow
key={envReqKey(entry)}
envKey={entry}
configured={configuredKeys.has(entry)}
draft={drafts[entry]}
onChange={onChange}
onSave={onSave}
/>
) : (
<AnyOfEnvGroup
key={envReqKey(entry)}
members={entry.any_of}
configuredKeys={configuredKeys}
drafts={drafts}
onChange={onChange}
onSave={onSave}
/>
),
)}
</ul>
</div>
);
}
interface StrictEnvRowProps {
envKey: string;
configured: boolean;
draft: DraftEntry | undefined;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
function StrictEnvRow({
envKey,
configured,
draft: d,
onChange,
onSave,
}: StrictEnvRowProps) {
return (
<li className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5">
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-zinc-500 line-through" : "text-zinc-200"
}`}
>
{envKey}
</code>
{configured ? (
<span className="text-[10px] text-emerald-400"> set</span>
) : (
<>
<input
type="password"
aria-label={`Value for ${envKey}`}
placeholder="paste value"
value={d?.value ?? ""}
onChange={(e) => onChange(envKey, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave(envKey);
}
}}
disabled={d?.saving}
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
/>
<button
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
</>
)}
{d?.error && (
<span className="text-[9px] text-red-400 basis-full pl-1">
{d.error}
</span>
)}
</li>
);
}
interface AnyOfEnvGroupProps {
members: string[];
configuredKeys: Set<string>;
drafts: Record<string, DraftEntry>;
onChange: (key: string, value: string) => void;
onSave: (key: string) => void;
}
/**
* Renders an OR group: the user only needs to configure ONE of the
* members to satisfy the requirement. Once any member is configured
* the group shows a green banner identifying the satisfying key; the
* other inputs remain visible but muted so the user can still switch
* providers if they want (uncommon but cheap to support).
*/
function AnyOfEnvGroup({
members,
configuredKeys,
drafts,
onChange,
onSave,
}: AnyOfEnvGroupProps) {
const satisfiedBy = members.find((m) => configuredKeys.has(m));
return (
<li className="rounded border border-zinc-800 bg-zinc-900/50 px-2.5 py-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] uppercase tracking-wide text-zinc-400">
Configure any one
</span>
{satisfiedBy && (
<span className="text-[10px] text-emerald-400">
using <code className="font-mono">{satisfiedBy}</code>
</span>
)}
</div>
<ul className="space-y-1.5">
{members.map((m) => {
const isConfigured = configuredKeys.has(m);
const d = drafts[m];
const dimmed = !!satisfiedBy && !isConfigured;
return (
<li
key={k}
className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5"
key={m}
className={`flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1 ${
dimmed ? "opacity-50" : ""
}`}
>
<code
className={`text-[11px] font-mono flex-1 ${
configured ? "text-zinc-500 line-through" : "text-zinc-200"
isConfigured ? "text-zinc-500 line-through" : "text-zinc-200"
}`}
>
{k}
{m}
</code>
{configured ? (
{isConfigured ? (
<span className="text-[10px] text-emerald-400"> set</span>
) : (
<>
<input
type="password"
aria-label={`Value for ${k}`}
aria-label={`Value for ${m}`}
placeholder="paste value"
value={d?.value ?? ""}
onChange={(e) => onChange(k, e.target.value)}
onChange={(e) => onChange(m, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSave(k);
onSave(m);
}
}}
disabled={d?.saving}
@ -307,7 +482,7 @@ function EnvList({
/>
<button
type="button"
onClick={() => onSave(k)}
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
@ -324,6 +499,6 @@ function EnvList({
);
})}
</ul>
</div>
</li>
);
}

View File

@ -6,7 +6,10 @@ import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import { OrgImportPreflightModal } from "./OrgImportPreflightModal";
import {
OrgImportPreflightModal,
type EnvRequirement,
} from "./OrgImportPreflightModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
import { showToast } from "./Toaster";
@ -27,13 +30,18 @@ export interface OrgTemplate {
/** Env vars that MUST be set as global secrets before the org can
* import. Server refuses the import with 412 if any are missing;
* the canvas preflights against /secrets/list to avoid the round
* trip. Aggregated from org-level + every workspace in the tree. */
required_env?: string[];
* trip. Aggregated from org-level + every workspace in the tree.
*
* Each entry is either a key name (strict) or an `{any_of: [...]}`
* group (any one of the listed members satisfies the requirement
* e.g. `ANTHROPIC_API_KEY` OR `CLAUDE_CODE_OAUTH_TOKEN`). */
required_env?: EnvRequirement[];
/** "Nice-to-have" tier. Import proceeds without them but features
* may degrade a channel's webhook posts get dropped, a fallback
* LLM isn't available, etc. Surfaced to the user as a non-blocking
* warning with an "add now" affordance. */
recommended_env?: string[];
* warning with an "add now" affordance. Same union shape as
* `required_env`. */
recommended_env?: EnvRequirement[];
}
/** Fetch the list of org templates from the platform. Returns [] on error

View File

@ -5,6 +5,7 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
@ -180,6 +181,108 @@ func NewOrgHandler(wh *WorkspaceHandler, b *events.Broadcaster, p *provisioner.P
}
}
// EnvRequirement is either a single env var name (strict: that exact
// var must be configured) or an any-of group (any one of the listed
// names satisfies the requirement).
//
// YAML shapes accepted:
//
// required_env:
// - GITHUB_TOKEN # single
// - any_of: [ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN] # OR group
//
// The any-of form exists because some runtimes accept either of two
// credential shapes — Claude Code takes ANTHROPIC_API_KEY or an OAuth
// token interchangeably, and forcing an org template to pick one
// would falsely block the other. For JSON (GET /org/templates),
// the same shapes round-trip: strings stay strings, groups stay
// {any_of: [...]}.
type EnvRequirement struct {
// Name is non-empty for a single required env var.
Name string
// AnyOf is non-empty for an OR group; any one member satisfies.
AnyOf []string
}
// Members returns every env name this requirement considers —
// [Name] for single, AnyOf for groups. Used by preflight, collect,
// and the name-validation regex gate.
func (e EnvRequirement) Members() []string {
if e.Name != "" {
return []string{e.Name}
}
return e.AnyOf
}
// IsSatisfied reports whether any member of the requirement is
// present in `configured`. Single: exact-match. AnyOf: at least
// one hit.
func (e EnvRequirement) IsSatisfied(configured map[string]struct{}) bool {
for _, m := range e.Members() {
if _, ok := configured[m]; ok {
return true
}
}
return false
}
// UnmarshalYAML accepts either a scalar (string → single) or a map
// with an `any_of` list (→ group).
func (e *EnvRequirement) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var s string
if err := value.Decode(&s); err != nil {
return err
}
e.Name = s
return nil
}
var alt struct {
AnyOf []string `yaml:"any_of"`
}
if err := value.Decode(&alt); err != nil {
return fmt.Errorf("env requirement must be a string or {any_of: [...]}: %w", err)
}
if len(alt.AnyOf) == 0 {
return fmt.Errorf("env requirement any_of must contain at least one env var")
}
e.AnyOf = alt.AnyOf
return nil
}
// MarshalJSON emits the dual shape so GET /org/templates callers get
// {"required_env": ["GITHUB_TOKEN", {"any_of": [...]}]}, matching
// the YAML syntax.
func (e EnvRequirement) MarshalJSON() ([]byte, error) {
if e.Name != "" {
return json.Marshal(e.Name)
}
return json.Marshal(struct {
AnyOf []string `json:"any_of"`
}{AnyOf: e.AnyOf})
}
// UnmarshalJSON is the inverse — accepts the same dual shape so
// POST /org/import with an inline `template` body works too.
func (e *EnvRequirement) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
e.Name = s
return nil
}
var alt struct {
AnyOf []string `json:"any_of"`
}
if err := json.Unmarshal(data, &alt); err != nil {
return fmt.Errorf("env requirement must be a string or {any_of: [...]}: %w", err)
}
if len(alt.AnyOf) == 0 {
return fmt.Errorf("env requirement any_of must contain at least one env var")
}
e.AnyOf = alt.AnyOf
return nil
}
// OrgTemplate is the YAML structure for an org hierarchy.
type OrgTemplate struct {
Name string `yaml:"name" json:"name"`
@ -189,20 +292,18 @@ type OrgTemplate struct {
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
// on the first root workspace (PM) during org import. Issue #1050.
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
// RequiredEnv is the union of env vars WHICH MUST be set globally (or
// RequiredEnv lists env vars that MUST be configured globally (or
// on every workspace in the subtree that needs them) before import
// will succeed. Declared at the org level for shared creds, also
// extensible per-workspace via OrgWorkspace.RequiredEnv for team-
// scoped credentials (e.g. LEGAL_VAULT_TOKEN only matters if the Legal
// subtree is part of this template). The canvas preflights both.
RequiredEnv []string `yaml:"required_env" json:"required_env"`
// succeeds. Each entry is either a plain string (strict) or an
// {any_of: [...]} group (at least one member must be set). Declared
// at the org level for shared creds; also extensible per-workspace
// via OrgWorkspace.RequiredEnv for team-scoped credentials.
RequiredEnv []EnvRequirement `yaml:"required_env" json:"required_env"`
// RecommendedEnv is the "nice-to-have" tier — import still succeeds
// without them, but features degrade. The canvas shows them as a
// yellow warning ("add now for best experience") rather than a
// blocking red. Example: SLACK_WEBHOOK_URL for a team whose agents
// occasionally post status updates, or ANTHROPIC_API_KEY when an
// agent might fall back to claude from its primary provider.
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
// without them, but features degrade. Same single|any_of shape as
// RequiredEnv so a recommended OR group reads "set any one of these
// to unlock the feature; all missing = warning".
RecommendedEnv []EnvRequirement `yaml:"recommended_env" json:"recommended_env"`
}
type OrgDefaults struct {
@ -315,10 +416,11 @@ type OrgWorkspace struct {
// OrgTemplate.RequiredEnv / RecommendedEnv. A workspace's subtree
// inherits: a parent declaring ANTHROPIC_API_KEY as required
// means every descendant considers it required too (no override
// needed at each leaf).
RequiredEnv []string `yaml:"required_env" json:"required_env"`
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
Children []OrgWorkspace `yaml:"children" json:"children"`
// needed at each leaf). Same single|any_of shape as the org-level
// lists.
RequiredEnv []EnvRequirement `yaml:"required_env" json:"required_env"`
RecommendedEnv []EnvRequirement `yaml:"recommended_env" json:"recommended_env"`
Children []OrgWorkspace `yaml:"children" json:"children"`
}
// ListTemplates handles GET /org/templates — lists available org templates.
@ -483,10 +585,14 @@ func (h *OrgHandler) Import(c *gin.Context) {
})
return
}
var missing []string
for _, k := range required {
if _, ok := configured[k]; !ok {
missing = append(missing, k)
var missing []EnvRequirement
for _, req := range required {
// For a single requirement this is exact-match; for an
// any-of group, any one member satisfies. Groups whose
// alternative is already configured drop out here — the
// user doesn't need to re-configure them.
if !req.IsSatisfied(configured) {
missing = append(missing, req)
}
}
if len(missing) > 0 {

View File

@ -555,56 +555,165 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// problem at a single choke point.
var envVarNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
// sanitizeEnvMembers filters a requirement's member list through the
// name-validation regex, logging rejections. Returns the filtered
// list and a boolean indicating whether any valid members remain.
// Used so a group containing one valid + one bogus name is kept
// (valid member carries the group) rather than silently dropped.
func sanitizeEnvMembers(members []string, where string) ([]string, bool) {
out := make([]string, 0, len(members))
for _, k := range members {
if !envVarNamePattern.MatchString(k) {
if k != "" {
log.Printf("collectOrgEnv: rejecting invalid env var name %q from %s (must match %s)", k, where, envVarNamePattern)
}
continue
}
out = append(out, k)
}
return out, len(out) > 0
}
// envRequirementKey canonicalises a requirement for dedup — sorted
// member list joined with NUL so `any_of: [A, B]` and `any_of: [B, A]`
// collapse to the same key. Single requirements are length-1 groups.
func envRequirementKey(members []string) string {
cp := append([]string(nil), members...)
sort.Strings(cp)
return strings.Join(cp, "\x00")
}
// collectOrgEnv walks the whole template tree and returns the union of
// required_env and recommended_env declared anywhere — at the org
// level, on root workspaces, or on any nested child. Deduplicates so
// the canvas sees a clean set. An env var declared as required at ANY
// level wins over recommended (required is strictly stricter).
// Entries that fail envVarNamePattern are dropped with a log line so
// a bad template surfaces in operator logs without breaking import.
func collectOrgEnv(tmpl *OrgTemplate) (required, recommended []string) {
req := map[string]struct{}{}
rec := map[string]struct{}{}
accept := func(into map[string]struct{}, src []string, where string) {
for _, k := range src {
if !envVarNamePattern.MatchString(k) {
if k != "" {
log.Printf("collectOrgEnv: rejecting invalid env var name %q from %s (must match %s)", k, where, envVarNamePattern)
}
// level, on root workspaces, or on any nested child. Deduplicates by
// group membership (same set of members = same requirement) and
// sorts deterministically so the canvas sees a stable order.
//
// "Required wins" rules:
//
// - A requirement that appears in BOTH required and recommended
// (same members) surfaces only as required.
// - A single-name requirement (e.g. "API_KEY") and a group that
// contains that same name (e.g. {any_of: [API_KEY, OTHER]}) are
// NOT deduplicated — they're semantically different (strict vs
// satisfiable-by-alternative) and the stricter "single" one wins,
// so the any-of group is dropped when its members overlap with a
// strict requirement declared elsewhere.
//
// Invalid names fail envVarNamePattern; the filter is applied per
// group so a group with one bogus entry keeps the rest. A group
// whose ALL members are invalid is dropped entirely with a log.
func collectOrgEnv(tmpl *OrgTemplate) (required, recommended []EnvRequirement) {
reqByKey := map[string]EnvRequirement{}
recByKey := map[string]EnvRequirement{}
// Names covered by strict (single) required entries. A group in
// EITHER tier whose any-of contains ONE of these names is
// dominated by the strict requirement and gets dropped on the
// second pass.
strictRequiredNames := map[string]struct{}{}
accept := func(into map[string]EnvRequirement, src []EnvRequirement, where string, markStrict bool) {
for _, req := range src {
members, ok := sanitizeEnvMembers(req.Members(), where)
if !ok {
continue
}
into[k] = struct{}{}
key := envRequirementKey(members)
if _, exists := into[key]; exists {
continue
}
if req.Name != "" && len(members) == 1 {
into[key] = EnvRequirement{Name: members[0]}
if markStrict {
strictRequiredNames[members[0]] = struct{}{}
}
} else {
into[key] = EnvRequirement{AnyOf: members}
}
}
}
accept(req, tmpl.RequiredEnv, "template root")
accept(rec, tmpl.RecommendedEnv, "template root")
accept(reqByKey, tmpl.RequiredEnv, "template root", true)
accept(recByKey, tmpl.RecommendedEnv, "template root", false)
var walk func([]OrgWorkspace)
walk = func(ws []OrgWorkspace) {
for _, w := range ws {
accept(req, w.RequiredEnv, "workspace "+w.Name)
accept(rec, w.RecommendedEnv, "workspace "+w.Name)
accept(reqByKey, w.RequiredEnv, "workspace "+w.Name, true)
accept(recByKey, w.RecommendedEnv, "workspace "+w.Name, false)
walk(w.Children)
}
}
walk(tmpl.Workspaces)
// Required wins — a key recommended at one layer and required at
// another surfaces only on the required side.
for k := range req {
delete(rec, k)
// Required wins across tiers: any requirement whose members
// overlap with a strict required name gets dropped from
// recommended. Keeps the canvas modal from showing the same
// key in both sections.
prune := func(from map[string]EnvRequirement) {
for k, r := range from {
for _, m := range r.Members() {
if _, strict := strictRequiredNames[m]; strict {
delete(from, k)
break
}
}
}
}
required = make([]string, 0, len(req))
for k := range req {
required = append(required, k)
prune(recByKey)
// Same-tier: a strict required X dominates any-of groups in
// required that CONTAIN X (a group saying "any of X, Y" is
// automatically satisfied when X is required anyway, so it's
// redundant). Same logic applied to recommended.
pruneSameTier := func(tier map[string]EnvRequirement) {
strictInTier := map[string]struct{}{}
for _, r := range tier {
if r.Name != "" {
strictInTier[r.Name] = struct{}{}
}
}
for k, r := range tier {
if len(r.AnyOf) == 0 {
continue
}
for _, m := range r.AnyOf {
if _, strict := strictInTier[m]; strict {
delete(tier, k)
break
}
}
}
}
recommended = make([]string, 0, len(rec))
for k := range rec {
recommended = append(recommended, k)
}
sort.Strings(required)
sort.Strings(recommended)
pruneSameTier(reqByKey)
pruneSameTier(recByKey)
required = flattenAndSortRequirements(reqByKey)
recommended = flattenAndSortRequirements(recByKey)
return required, recommended
}
func flattenAndSortRequirements(by map[string]EnvRequirement) []EnvRequirement {
out := make([]EnvRequirement, 0, len(by))
for _, r := range by {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
// Sort singles first by name; groups after, ordered by
// joined-member string. Gives the canvas a deterministic
// render order so the same template always produces the
// same modal layout.
iSingle := out[i].Name != ""
jSingle := out[j].Name != ""
if iSingle != jSingle {
return iSingle
}
if iSingle {
return out[i].Name < out[j].Name
}
return envRequirementKey(out[i].AnyOf) < envRequirementKey(out[j].AnyOf)
})
return out
}
// loadConfiguredGlobalSecretKeys returns the set of key names present
// in global_secrets WHERE the encrypted_value is non-empty. Filtering
// on the payload size catches the failure mode where a row was

View File

@ -1,6 +1,7 @@
package handlers
import (
"sort"
"strings"
"testing"
"time"
@ -655,19 +656,54 @@ func TestOrgImport_ScheduleComputeError(t *testing.T) {
// Org env-preflight aggregation (collectOrgEnv)
// ============================================================================
// strictReq builds a slice of single-name EnvRequirements for test
// fixtures. Equivalent to the old []string literal but wrapped in
// the new union shape.
func strictReq(names ...string) []EnvRequirement {
out := make([]EnvRequirement, 0, len(names))
for _, n := range names {
out = append(out, EnvRequirement{Name: n})
}
return out
}
// anyOfReq builds a single any-of EnvRequirement for test fixtures.
func anyOfReq(names ...string) EnvRequirement {
return EnvRequirement{AnyOf: append([]string(nil), names...)}
}
// reqNames flattens a slice of EnvRequirements into a single comparable
// slice: single-name reqs contribute their Name, any-of reqs contribute
// "anyOf(A|B|C)" with members sorted for deterministic output. Lets
// tests assert against a string form regardless of which kind each
// entry takes.
func reqNames(reqs []EnvRequirement) []string {
out := make([]string, 0, len(reqs))
for _, r := range reqs {
if r.Name != "" {
out = append(out, r.Name)
continue
}
members := append([]string(nil), r.AnyOf...)
sort.Strings(members)
out = append(out, "anyOf("+strings.Join(members, "|")+")")
}
return out
}
func TestCollectOrgEnv_UnionAcrossLevels(t *testing.T) {
tmpl := &OrgTemplate{
RequiredEnv: []string{"ANTHROPIC_API_KEY"},
RecommendedEnv: []string{"SLACK_WEBHOOK_URL"},
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
RecommendedEnv: strictReq("SLACK_WEBHOOK_URL"),
Workspaces: []OrgWorkspace{
{
Name: "Root",
RequiredEnv: []string{"GITHUB_TOKEN"},
Name: "Root",
RequiredEnv: strictReq("GITHUB_TOKEN"),
Children: []OrgWorkspace{
{
Name: "Leaf",
RequiredEnv: []string{"OPENROUTER_API_KEY"},
RecommendedEnv: []string{"DISCORD_WEBHOOK_URL"},
RequiredEnv: strictReq("OPENROUTER_API_KEY"),
RecommendedEnv: strictReq("DISCORD_WEBHOOK_URL"),
},
},
},
@ -676,12 +712,12 @@ func TestCollectOrgEnv_UnionAcrossLevels(t *testing.T) {
req, rec := collectOrgEnv(tmpl)
// Required is the union of top-level + root + leaf.
wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN", "OPENROUTER_API_KEY"}
if !stringSlicesEqual(req, wantReq) {
t.Errorf("required mismatch: got %v, want %v", req, wantReq)
if !stringSlicesEqual(reqNames(req), wantReq) {
t.Errorf("required mismatch: got %v, want %v", reqNames(req), wantReq)
}
wantRec := []string{"DISCORD_WEBHOOK_URL", "SLACK_WEBHOOK_URL"}
if !stringSlicesEqual(rec, wantRec) {
t.Errorf("recommended mismatch: got %v, want %v", rec, wantRec)
if !stringSlicesEqual(reqNames(rec), wantRec) {
t.Errorf("recommended mismatch: got %v, want %v", reqNames(rec), wantRec)
}
}
@ -691,17 +727,17 @@ func TestCollectOrgEnv_RequiredWinsOverRecommended(t *testing.T) {
// declaration is strictly stricter than a recommended one, and
// listing it in both tiers would confuse the preflight modal.
tmpl := &OrgTemplate{
RecommendedEnv: []string{"API_KEY"},
RecommendedEnv: strictReq("API_KEY"),
Workspaces: []OrgWorkspace{
{Name: "X", RequiredEnv: []string{"API_KEY"}},
{Name: "X", RequiredEnv: strictReq("API_KEY")},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "API_KEY" {
t.Errorf("required should contain API_KEY, got %v", req)
if len(req) != 1 || req[0].Name != "API_KEY" {
t.Errorf("required should contain API_KEY, got %v", reqNames(req))
}
for _, k := range rec {
if k == "API_KEY" {
for _, r := range rec {
if r.Name == "API_KEY" {
t.Errorf("API_KEY must not appear in recommended once required elsewhere")
}
}
@ -710,17 +746,17 @@ func TestCollectOrgEnv_RequiredWinsOverRecommended(t *testing.T) {
func TestCollectOrgEnv_Dedup(t *testing.T) {
// Same key declared twice at different levels should appear once.
tmpl := &OrgTemplate{
RequiredEnv: []string{"K", "K"},
RequiredEnv: strictReq("K", "K"),
Workspaces: []OrgWorkspace{
{Name: "A", RequiredEnv: []string{"K"}},
{Name: "B", RequiredEnv: []string{"K"}, Children: []OrgWorkspace{
{Name: "C", RequiredEnv: []string{"K"}},
{Name: "A", RequiredEnv: strictReq("K")},
{Name: "B", RequiredEnv: strictReq("K"), Children: []OrgWorkspace{
{Name: "C", RequiredEnv: strictReq("K")},
}},
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "K" {
t.Errorf("dedup failed: got %v, want [K]", req)
if len(req) != 1 || req[0].Name != "K" {
t.Errorf("dedup failed: got %v, want [K]", reqNames(req))
}
}
@ -728,7 +764,7 @@ func TestCollectOrgEnv_Empty(t *testing.T) {
tmpl := &OrgTemplate{}
req, rec := collectOrgEnv(tmpl)
if len(req) != 0 || len(rec) != 0 {
t.Errorf("empty template should produce empty slices, got req=%v rec=%v", req, rec)
t.Errorf("empty template should produce empty slices, got req=%v rec=%v", reqNames(req), reqNames(rec))
}
}
@ -754,17 +790,17 @@ func TestCollectOrgEnv_RequiredWinsOnSameStruct(t *testing.T) {
Workspaces: []OrgWorkspace{
{
Name: "X",
RequiredEnv: []string{"API_KEY"},
RecommendedEnv: []string{"API_KEY"},
RequiredEnv: strictReq("API_KEY"),
RecommendedEnv: strictReq("API_KEY"),
},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0] != "API_KEY" {
t.Errorf("required should contain API_KEY once, got %v", req)
if len(req) != 1 || req[0].Name != "API_KEY" {
t.Errorf("required should contain API_KEY once, got %v", reqNames(req))
}
for _, k := range rec {
if k == "API_KEY" {
for _, r := range rec {
if r.Name == "API_KEY" {
t.Errorf("API_KEY must not appear in recommended when also required on same struct")
}
}
@ -776,7 +812,7 @@ func TestCollectOrgEnv_RejectsInvalidNames(t *testing.T) {
// asserted here; the output slice assertion is enough to prove the
// filter fires.
tmpl := &OrgTemplate{
RequiredEnv: []string{
RequiredEnv: strictReq(
"VALID_ONE",
"lowercase_bad",
"../../etc/passwd",
@ -785,10 +821,191 @@ func TestCollectOrgEnv_RejectsInvalidNames(t *testing.T) {
"'; DROP TABLE users;--",
"",
"A", // single char — still valid per regex
),
}
req, _ := collectOrgEnv(tmpl)
if !stringSlicesEqual(reqNames(req), []string{"A", "VALID_ONE"}) {
t.Errorf("expected only valid names, got %v", reqNames(req))
}
}
// TestEnvRequirement_UnmarshalYAML proves the on-disk YAML shape
// (scalar OR `{any_of: [...]}` block) round-trips into EnvRequirement
// correctly. The preflight pipeline reads user-authored org.yaml
// files; a regression here would silently drop requirements.
func TestEnvRequirement_UnmarshalYAML(t *testing.T) {
src := `
required_env:
- GITHUB_TOKEN
- any_of:
- ANTHROPIC_API_KEY
- CLAUDE_CODE_OAUTH_TOKEN
`
var parsed struct {
RequiredEnv []EnvRequirement `yaml:"required_env"`
}
if err := yaml.Unmarshal([]byte(src), &parsed); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if len(parsed.RequiredEnv) != 2 {
t.Fatalf("want 2 requirements, got %d", len(parsed.RequiredEnv))
}
if parsed.RequiredEnv[0].Name != "GITHUB_TOKEN" {
t.Errorf("first should be strict GITHUB_TOKEN, got %+v", parsed.RequiredEnv[0])
}
if parsed.RequiredEnv[1].Name != "" || len(parsed.RequiredEnv[1].AnyOf) != 2 {
t.Errorf("second should be any-of group, got %+v", parsed.RequiredEnv[1])
}
}
// TestEnvRequirement_UnmarshalYAML_RejectsEmptyAnyOf guards against a
// template that ships `any_of: []` — ambiguous semantics (impossible
// to satisfy), so the parser must fail loudly rather than silently
// pass a never-satisfiable requirement through the preflight.
func TestEnvRequirement_UnmarshalYAML_RejectsEmptyAnyOf(t *testing.T) {
src := `
required_env:
- any_of: []
`
var parsed struct {
RequiredEnv []EnvRequirement `yaml:"required_env"`
}
err := yaml.Unmarshal([]byte(src), &parsed)
if err == nil {
t.Errorf("expected error for empty any_of, got nil: %+v", parsed)
}
}
// ---------------------------------------------------------------------
// any_of group tests — the new EnvRequirement union shape allows a
// single requirement to be satisfied by any of a list of members (e.g.
// ANTHROPIC_API_KEY OR CLAUDE_CODE_OAUTH_TOKEN). collectOrgEnv +
// IsSatisfied together must handle this correctly.
// ---------------------------------------------------------------------
func TestEnvRequirement_IsSatisfied(t *testing.T) {
configured := map[string]struct{}{
"ANTHROPIC_API_KEY": {},
"GITHUB_TOKEN": {},
}
tests := []struct {
name string
req EnvRequirement
want bool
}{
{"strict present", EnvRequirement{Name: "ANTHROPIC_API_KEY"}, true},
{"strict absent", EnvRequirement{Name: "MISSING_KEY"}, false},
{"any-of first member present", anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"), true},
{"any-of second member present", anyOfReq("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), true},
{"any-of none present", anyOfReq("OPENAI_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"), false},
{"any-of single member present", anyOfReq("GITHUB_TOKEN"), true},
}
for _, tt := range tests {
if got := tt.req.IsSatisfied(configured); got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
}
}
}
func TestCollectOrgEnv_AnyOfGroupPreserved(t *testing.T) {
// A group with two alternatives should come through as a single
// EnvRequirement carrying both members.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
},
}
req, _ := collectOrgEnv(tmpl)
if !stringSlicesEqual(req, []string{"A", "VALID_ONE"}) {
t.Errorf("expected only valid names, got %v", req)
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, got strict name %q", req[0].Name)
}
wantMembers := []string{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"}
got := append([]string(nil), req[0].AnyOf...)
sort.Strings(got)
if !stringSlicesEqual(got, wantMembers) {
t.Errorf("any-of members mismatch: got %v, want %v", got, wantMembers)
}
}
func TestCollectOrgEnv_AnyOfGroupDedup(t *testing.T) {
// Two identical groups (members in different order) declared at
// different levels must collapse to one.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
},
Workspaces: []OrgWorkspace{
{
Name: "Root",
RequiredEnv: []EnvRequirement{
anyOfReq("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"),
},
},
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Errorf("expected 1 requirement after dedup, got %d: %v", len(req), reqNames(req))
}
}
func TestCollectOrgEnv_StrictDominatesGroup(t *testing.T) {
// If a strict requirement X is declared anywhere, any-of groups
// that CONTAIN X are redundant — the strict requirement will force
// X to be configured, which satisfies any group mentioning it too.
// Same-tier pruning drops the group.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
{Name: "ANTHROPIC_API_KEY"},
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 || req[0].Name != "ANTHROPIC_API_KEY" {
t.Errorf("strict should dominate group, got %v", reqNames(req))
}
}
func TestCollectOrgEnv_StrictRequiredDominatesRecommendedGroup(t *testing.T) {
// Cross-tier: a strict required X drops any-of groups in the
// recommended tier that mention X.
tmpl := &OrgTemplate{
RequiredEnv: strictReq("ANTHROPIC_API_KEY"),
RecommendedEnv: []EnvRequirement{
anyOfReq("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"),
{Name: "SLACK_WEBHOOK_URL"},
},
}
req, rec := collectOrgEnv(tmpl)
if len(req) != 1 || req[0].Name != "ANTHROPIC_API_KEY" {
t.Errorf("required mismatch: got %v", reqNames(req))
}
// The any-of group should have been pruned; only SLACK remains.
if len(rec) != 1 || rec[0].Name != "SLACK_WEBHOOK_URL" {
t.Errorf("recommended mismatch: got %v, want [SLACK_WEBHOOK_URL]", reqNames(rec))
}
}
func TestCollectOrgEnv_AnyOfWithInvalidMemberKeepsValidOnes(t *testing.T) {
// A group with one valid + one invalid member should keep the
// valid one (group carried by any remaining legitimate name). A
// group where ALL members are invalid is dropped entirely.
tmpl := &OrgTemplate{
RequiredEnv: []EnvRequirement{
anyOfReq("VALID_ONE", "lowercase_bad"),
anyOfReq("'; DROP TABLE;--", ""),
},
}
req, _ := collectOrgEnv(tmpl)
if len(req) != 1 {
t.Fatalf("expected 1 requirement, got %d: %v", len(req), reqNames(req))
}
// The remaining group has only one valid member, so it gets
// promoted to a single-name requirement (len(members)==1 path).
if req[0].Name != "VALID_ONE" && !stringSlicesEqual(req[0].AnyOf, []string{"VALID_ONE"}) {
t.Errorf("expected VALID_ONE to survive, got %v", reqNames(req))
}
}