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:
parent
f995b90a85
commit
ad73a56db1
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user