agentenv

package
v2.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package agentenv parses the optional .agents/env.yaml (or .env.json) manifest and produces a Resolver that agent-bundle loaders can use to interpolate ${env:VAR} references in AGENTS.md, skill files, and mcp.json values.

The mechanism has two halves:

  1. A machine-readable manifest declaring which env vars the bundle expects — required vs optional, defaults, sensitive markers, and free-text descriptions. The daemon validates required vars at boot; missing required vars are fail-loud errors, not silent empty-string interpolation.

  2. ${env:VAR} interpolation applied at instruction-file load time. Syntax matches what pkg/mcp/config.go already accepts in mcp.json header values, so operators only learn one substitution convention across the whole .agents/ bundle.

Bundles without a manifest keep working unchanged — no manifest, no interpolation, no validation. Zero regression path for existing deployments.

Index

Constants

View Source
const ManifestFileJSON = "env.json"

ManifestFileJSON is the JSON variant of the manifest. Both files are probed at load time — YAML first, then JSON. Declaring both is a configuration error (see LoadManifest).

View Source
const ManifestFileYAML = "env.yaml"

ManifestFileYAML is the manifest filename with YAML syntax. YAML is the preferred format because multi-line descriptions read cleanly; JSON is supported below for parity with config.json / mcp.json.

View Source
const SchemaVersion = 1

SchemaVersion is the current major version of the env-manifest file format. Bump on breaking changes; older versions get rejected at load time with an explicit upgrade-path error.

Variables

This section is empty.

Functions

func FindReferences

func FindReferences(s string) []string

FindReferences returns the unique set of NAME values referenced in s via the ${env:NAME} syntax. Order is deterministic (sorted) so callers can produce stable log lines and diff output.

Used by the Resolver during construction to (a) compute the set of names referenced anywhere in the bundle for the "undeclared reference" drift warning, and (b) enable "unreferenced declaration" detection on the manifest side.

func InterpolateEnv

func InterpolateEnv(s string) string

InterpolateEnv is the legacy free-function used by pkg/mcp. It looks up each ${env:NAME} directly via os.Getenv with no manifest awareness — no required-var checks, no sensitive tracking, no drift warnings. Kept exported so pkg/mcp callers didn't churn when interpolation moved into this package; new call sites should go through a Resolver instead (see NewResolver).

func InterpolateMap

func InterpolateMap(m map[string]string) map[string]string

InterpolateMap returns a copy of m with each value run through InterpolateEnv. Nil / empty maps pass through as nil (matches the pre-agentenv pkg/mcp semantics for absent headers / env blocks).

Types

type Entry

type Entry struct {
	Name        string   `json:"name"                   yaml:"name"`
	Required    bool     `json:"required,omitempty"     yaml:"required,omitempty"`
	Default     string   `json:"default,omitempty"      yaml:"default,omitempty"`
	Sensitive   bool     `json:"sensitive,omitempty"    yaml:"sensitive,omitempty"`
	Description string   `json:"description,omitempty"  yaml:"description,omitempty"`
	UsedBy      []string `json:"used_by,omitempty"      yaml:"used_by,omitempty"`
}

Entry declares one env variable the bundle expects.

A required entry with no env-var set at daemon boot is a fail-loud error (surfaced via Resolver.Errors). An optional entry falls back to Default when unset; missing default → empty string.

UsedBy is optional documentation — a comma-separated list of files (or free-text hints) that reference this var, letting recipe authors grep for coupling. Nothing in the loader validates the entries.

Sensitive marks values that must not appear in verbose logs, the eventlog, or diagnostic surfaces like /stats. Set true for tokens, passwords, API keys.

type Manifest

type Manifest struct {
	Version int     `json:"version" yaml:"version"`
	Env     []Entry `json:"env" yaml:"env"`
}

Manifest is the on-disk schema for .agents/env.yaml (or .env.json).

Version follows the convention set by pkg/config (SchemaVersion=1) and pkg/mcp (Servers.Version) — top-level version bump on breaking change, unknown fields ignored for forward-compat.

func LoadManifest

func LoadManifest(agentsDir string) (*Manifest, error)

LoadManifest probes agentsDir for env.yaml then env.json and returns whichever it finds. Both present → error (ambiguous; operator should pick one). Neither present → nil manifest, nil error (caller treats as "no interpolation configured"; existing bundles keep working).

agentsDir may be "" — in that case returns (nil, nil) too, matching how config.LoadOrDefault handles the "no .agents/ discovered" case.

func (*Manifest) Validate

func (m *Manifest) Validate() error

Validate reports schema-level errors in the manifest itself (bad version, duplicate names, empty name field). It does NOT check whether required env vars are set at runtime — that check is Resolver's job because it needs an env-lookup fn to run.

type ResidualRefs added in v2.9.0

type ResidualRefs struct {
	// contains filtered or unexported fields
}

ResidualRefs records ${env:...} placeholders that are still literally present in loaded bundle content AFTER the interpolation pass ran — or, in the case this exists for, after it silently did not.

The problem it solves (#712): interpolation is gated on an optional manifest. No .agents/env.yaml means LoadManifest returns nil, which means NewResolver returns a nil *Resolver, which means InterpolateFunc returns nil, which every loader documents as "no interpolation" and honours by passing bodies through untouched. Each link is correct in isolation and the composition is a no-op that announces nothing, so ${env:GOOGLE_CLOUD_PROJECT} reaches the model as literal persona text and reads to it like a value.

Scope is the manifest-gated loaders — instruction files and skills, for the parent and for rooted subagents. mcp.json is NOT affected and is not tracked: pkg/mcp interpolates Env / Headers through InterpolateEnv, which reads the ambient process env with no manifest involved, so nothing there can survive for want of one.

Detection is deliberately placed on the loaded *content* rather than on the manifest's absence. Content is the thing that actually goes wrong: a bundle with no manifest and no placeholders is fine and must stay quiet, and a bundle with a manifest can still ship a placeholder the interpolator will never match (see residualRe) — which no manifest-shaped check could catch, and which ReportDrift is structurally blind to, since it reports names Interpolate SAW and a non-matching placeholder is never seen.

A ResidualRefs is safe for concurrent use and safe to use as the zero value. Nil-safe throughout, matching Resolver's conventions.

func (*ResidualRefs) Placeholders added in v2.9.0

func (t *ResidualRefs) Placeholders() []string

Placeholders returns the unique surviving placeholders, verbatim and sorted, so callers can produce stable output. Empty when nothing survived — which is the healthy case, including for the many bundles that use no ${env:} references at all.

func (*ResidualRefs) Track added in v2.9.0

func (t *ResidualRefs) Track(r *Resolver) func(string) string

Track returns the interpolator to hand every content loader: r's substitution (none, when r is nil) followed by a scan of the result for placeholders that survived it.

The returned function is non-nil even when r is nil, and that is the whole point rather than an implementation detail. A nil *Resolver yields a nil InterpolateFunc, loaders skip a nil interpolator entirely, and so the one path that most needs observing is the one that used to be unobservable. Cost is one substring check per file body plus, only for bodies that pass it, one regex scan. It does mean skill .md bodies now take a string round-trip through pkg/skills' sanitizing filesystem where a nil interpolator used to short-circuit; against the file read that precedes it, that is noise.

Track also records r, so Warning can describe the right cause without the caller having to hand it back a second time and get it right. Call it once per resolver, before the loaders run.

Nil receiver returns r.InterpolateFunc() unchanged, so a caller that wants no tracking need not special-case the wiring.

func (*ResidualRefs) Warning added in v2.9.0

func (t *ResidualRefs) Warning(agentsDir string) string

Warning formats the boot diagnostic for whatever survived, or "" when nothing did. The caller emits it through the same startup-line channel as the instruction/skill/subagent lines.

Whether a manifest was active changes both the diagnosis and the remedy, so the two cases get different text rather than one line hedging between them; Track recorded which one applies. agentsDir names the directory the manifest would have been read from, and is "" when no .agents/ was discovered at all — a third thing worth saying out loud, since "add env.yaml" is not actionable advice when there is nowhere to put it.

Warning-not-error is a deliberate choice: ${env:...} appears legitimately as literal text in content that *documents* the feature. Two of the three skill bundles this repo ships under SKILLS/ quote it inside example mcp.json blocks, and SKILLS/README.md tells operators to copy those bundles straight into .agents/skills/ — so refusing to boot on a surviving placeholder would turn a documentation string into an outage.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver interpolates ${env:NAME} in strings using an env-var lookup, with awareness of which names are declared in the manifest.

Construction is a two-phase process:

  1. NewResolver(manifest, lookup) — records the manifest, resolves each declared name against lookup, records errors for missing required vars. This is when required-var validation fires.

  2. Interpolation call sites (pkg/instruction, pkg/skills, pkg/mcp) use Interpolate(s) as they load bundle files. Interpolate records every unique NAME it sees to enable the "undeclared reference" drift warning at ReportDrift time.

A nil Resolver is safe — Interpolate is a no-op, Errors returns nil, IsSensitive returns false. Loaders should tolerate the nil case (bundle without a manifest) rather than requiring a stub.

func NewResolver

func NewResolver(manifest *Manifest, lookup func(name string) (string, bool)) *Resolver

NewResolver builds a Resolver from a parsed manifest and an env-var lookup function (usually os.LookupEnv). Passing nil manifest returns nil — matches the "no manifest, no interpolation" backwards-compat path expected by pkg/config.LoadOrDefault callers.

lookup must return (value, true) if the var is set (even to empty string) and ("", false) if unset. os.LookupEnv has this shape directly; tests can pass a map-backed closure.

func (*Resolver) Errors

func (r *Resolver) Errors() []error

Errors returns fatal validation problems from Resolver construction (currently: missing required env vars). Empty slice → boot may proceed; non-empty → boot should log each and exit.

func (*Resolver) Interpolate

func (r *Resolver) Interpolate(s string) string

Interpolate substitutes ${env:NAME} in s using the resolved manifest values. Undeclared NAMEs fall back to the ambient os.Getenv path so a bundle can still reference standard system env vars (HOME, PATH, etc.) without declaring them — those show up as "undeclared reference" warnings via ReportDrift but don't break interpolation.

Every unique NAME seen is recorded so ReportDrift can compute the undeclared-reference set.

func (*Resolver) InterpolateFunc

func (r *Resolver) InterpolateFunc() func(string) string

InterpolateFunc returns a bare closure suitable for passing to loaders that don't want to import agentenv directly (pkg/instruction, pkg/skills). Nil-safe: nil Resolver returns nil, which loaders interpret as "no interpolation."

func (*Resolver) IsSensitive

func (r *Resolver) IsSensitive(name string) bool

IsSensitive reports whether the named var is marked sensitive in the manifest. Used by log-sanitization paths that already redact certain values (mcp.json headers, /stats surfaces) to also redact env-var values marked in the manifest. Nil-safe: nil Resolver → false.

func (*Resolver) NoteConfigRefs added in v2.9.0

func (r *Resolver) NoteConfigRefs(names []string)

NoteConfigRefs records env-var names the CONFIG refers to by name — the `*_env` fields (alerts.targets[].url_env, attach.token_env, auth.bearer_env, …) whose value is a var name rather than a secret.

These never reach Interpolate: config is parsed by pkg/config and resolved late by whichever component owns the field, so it never flows through this resolver. Without this call ReportDrift sees only ${env:NAME} references in bundle text and reports a config-only var as unreferenced — which is how the kube-platform-native deployment came to warn that nothing referenced PLATFORM_AGENT_ALERT_WEBHOOK while the alert target was reading it by name.

Recorded separately from seenRefs rather than folded in, so the undeclared-reference warning can name the right convention: telling an operator to add `${env:FOO}` to their manifest when FOO is a bearer_env would send them looking for a bundle reference that does not exist.

Nil-safe and idempotent. Callers pass (*config.Config).EnvRefs().

func (*Resolver) ReportDrift

func (r *Resolver) ReportDrift() []string

ReportDrift returns non-fatal warnings the daemon should log at boot:

  • "undeclared reference: BAR referenced in bundle but not in manifest" — the recipe author probably meant to add it.
  • "unreferenced declaration: FOO declared in manifest but not referenced anywhere" — leftover from a refactor.

A name counts as referenced if EITHER convention reaches it: a ${env:NAME} in bundle text (recorded by Interpolate) or a `*_env` config field naming it (recorded by NoteConfigRefs). The undeclared warning names whichever convention actually made the reference, so the operator is pointed at the file they'd have to edit.

Both are advisory (per the #322 issue: warn, not error). The daemon keeps running; the recipe author sees the warnings and cleans up on their next iteration.

Callers must invoke ReportDrift AFTER all bundle files have flowed through Interpolate at least once AND after NoteConfigRefs; earlier invocation reports every declaration as unreferenced.

func (*Resolver) SensitiveValues

func (r *Resolver) SensitiveValues() []string

SensitiveValues returns the set of resolved values that should be redacted in logs, sorted for stable output. Empty when no sensitive entries are declared or nothing has been resolved yet.

Callers that need to grep-and-redact a downstream string (like a full log line) can walk this list; callers that already know which VAR they're logging should prefer IsSensitive.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL