diff --git a/cmd/helpers.go b/cmd/helpers.go index 3208431..8b0fa71 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -13,12 +13,12 @@ import ( "syscall" "time" - "github.com/charmbracelet/huh" "github.com/Infisical/agent-vault/internal/auth" "github.com/Infisical/agent-vault/internal/pidfile" "github.com/Infisical/agent-vault/internal/session" "github.com/Infisical/agent-vault/internal/store" "github.com/Infisical/agent-vault/internal/telemetry" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -480,20 +480,13 @@ func ensureSession() (*session.ClientSession, error) { return sess, nil } -// ProjectConfigFile is the name of the project-level vault binding file. -const ProjectConfigFile = "agent-vault.json" - -// loadProjectVault reads agent-vault.json from the working directory. +// loadProjectVault reads the nearest agent-vault.json up to the repository +// root. Invalid files preserve the legacy behavior of returning no binding; +// profile-based runs report those errors explicitly through resolveRunProfile. // Returns the vault name or "" if the file doesn't exist or is invalid. func loadProjectVault() string { - data, err := os.ReadFile(ProjectConfigFile) - if err != nil { - return "" - } - var cfg struct { - Vault string `json:"vault"` - } - if json.Unmarshal(data, &cfg) != nil { + cfg, _, found, err := loadProjectConfig() + if err != nil || !found { return "" } return cfg.Vault @@ -536,12 +529,24 @@ const ( // // If a token is set but AGENT_VAULT_ADDR is missing, returns a clear error // rather than silently falling through to interactive login — masking that -// misconfig produces "why don't my creds work" tickets. +// misconfig produces "why don't my creds work" tickets. The run command uses +// resolveSessionWithAddress so --address or a profile can satisfy this too. func resolveSession() (*session.ClientSession, string, error) { + return resolveSessionWithAddress("") +} + +// resolveSessionWithAddress is the run-command variant of resolveSession. It +// lets --address or a project profile supply the broker address in agent mode, +// while keeping resolveSession's existing environment-only contract for other +// commands. +func resolveSessionWithAddress(address string) (*session.ClientSession, string, error) { token := os.Getenv(envVarToken) - addr := os.Getenv("AGENT_VAULT_ADDR") + addr := address + if addr == "" { + addr = os.Getenv("AGENT_VAULT_ADDR") + } if token != "" && addr == "" { - return nil, "", fmt.Errorf("%s is set but AGENT_VAULT_ADDR is empty — both are required for agent mode", envVarToken) + return nil, "", fmt.Errorf("%s is set but no server address is configured — set AGENT_VAULT_ADDR, pass --address, or select a profile with address", envVarToken) } if token != "" { return &session.ClientSession{Token: token, Address: strings.TrimRight(addr, "/")}, envVarToken, nil @@ -747,4 +752,3 @@ func validInstanceRole(s string) bool { } return false } - diff --git a/cmd/project_config.go b/cmd/project_config.go new file mode 100644 index 0000000..9ae5334 --- /dev/null +++ b/cmd/project_config.go @@ -0,0 +1,269 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/Infisical/agent-vault/internal/isolation" + "github.com/spf13/cobra" +) + +// ProjectConfigFile is the name of the project-level Agent Vault config. +const ProjectConfigFile = "agent-vault.json" + +// ProjectConfigVersion is the current version of the profiles schema. Legacy +// files containing only {"vault":"..."} intentionally omit it. +const ProjectConfigVersion = 1 + +type projectConfig struct { + Version int `json:"version,omitempty"` + Address string `json:"address,omitempty"` + Vault string `json:"vault,omitempty"` + Profiles map[string]projectProfile `json:"profiles,omitempty"` +} + +type projectProfile struct { + Vault string `json:"vault"` + Env map[string]string `json:"env,omitempty"` +} + +type resolvedRunProfile struct { + Address string + Vault string + Env map[string]string +} + +const resolvedRunProfileVaultAnnotation = "agent-vault.dev/run-profile-vault" + +var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// findProjectConfig walks from the working directory toward its repository +// root. The nearest file wins. A .git file counts as a repository root too, +// which is important for Git worktrees. +func findProjectConfig() (string, bool, error) { + dir, err := os.Getwd() + if err != nil { + return "", false, fmt.Errorf("resolve working directory: %w", err) + } + + for { + path := filepath.Join(dir, ProjectConfigFile) + if _, err := os.Stat(path); err == nil { + return path, true, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", false, fmt.Errorf("inspect %s: %w", path, err) + } + + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return "", false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", false, fmt.Errorf("inspect repository boundary in %s: %w", dir, err) + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", false, nil + } + dir = parent + } +} + +func loadProjectConfig() (*projectConfig, string, bool, error) { + path, found, err := findProjectConfig() + if err != nil || !found { + return nil, path, found, err + } + + data, err := os.ReadFile(path) //nolint:gosec // path is discovered from the user's working tree. + if err != nil { + return nil, path, true, fmt.Errorf("read %s: %w", path, err) + } + var cfg projectConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, path, true, fmt.Errorf("parse %s: %w", path, err) + } + if err := validateProjectConfig(&cfg); err != nil { + return nil, path, true, fmt.Errorf("validate %s: %w", path, err) + } + return &cfg, path, true, nil +} + +func validateProjectConfig(cfg *projectConfig) error { + if cfg.Version != 0 && cfg.Version != ProjectConfigVersion { + return fmt.Errorf("unsupported version %d (supported: %d)", cfg.Version, ProjectConfigVersion) + } + if (cfg.Address != "" || len(cfg.Profiles) > 0) && cfg.Version != ProjectConfigVersion { + return fmt.Errorf("profiles and address require version %d", ProjectConfigVersion) + } + if cfg.Address != "" { + address, err := normalizeProjectAddress(cfg.Address) + if err != nil { + return err + } + cfg.Address = address + } + + profileNames := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + profile := cfg.Profiles[name] + if !isSlug(name) { + return fmt.Errorf("profile name %q must use lowercase letters, numbers, and hyphens", name) + } + if profile.Vault == "" { + return fmt.Errorf("profile %q must set vault", name) + } + if !isSlug(profile.Vault) { + return fmt.Errorf("vault %q in profile %q must use lowercase letters, numbers, and hyphens", profile.Vault, name) + } + envKeys := make([]string, 0, len(profile.Env)) + for key := range profile.Env { + envKeys = append(envKeys, key) + } + sort.Strings(envKeys) + for _, key := range envKeys { + value := profile.Env[key] + if err := validateProfileEnvKey(key); err != nil { + return fmt.Errorf("profile %q env key %q: %w", name, key, err) + } + if strings.ContainsRune(value, '\x00') { + return fmt.Errorf("profile %q env key %q: value must not contain a NUL byte", name, key) + } + } + } + return nil +} + +func normalizeProjectAddress(address string) (string, error) { + u, err := url.Parse(address) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", fmt.Errorf("address must be an absolute http or https URL") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("address must not contain user info, a query, or a fragment") + } + if u.Path != "" && u.Path != "/" { + return "", fmt.Errorf("address must not contain a path") + } + u.Path = "" + return strings.TrimRight(u.String(), "/"), nil +} + +func isSlug(value string) bool { + if value == "" || value[0] == '-' || value[len(value)-1] == '-' { + return false + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } + } + return true +} + +func validateProfileEnvKey(key string) error { + if !envNamePattern.MatchString(key) { + return errors.New("must be a valid environment variable name") + } + upper := strings.ToUpper(key) + if upper == "PATH" || upper == "HOME" || strings.HasPrefix(upper, "LD_") || strings.HasPrefix(upper, "DYLD_") { + return errors.New("is reserved and cannot be set by a profile") + } + if strings.HasPrefix(upper, "AGENT_VAULT_") { + return errors.New("is managed by Agent Vault") + } + if upper == "ALL_PROXY" { + return errors.New("is managed by Agent Vault") + } + for _, managed := range isolation.ProxyEnvKeys { + if upper == managed { + return errors.New("is managed by Agent Vault") + } + } + return nil +} + +func resolveRunProfile(cmd *cobra.Command) (*resolvedRunProfile, error) { + name, _ := cmd.Flags().GetString("profile") + if name == "" { + return nil, nil + } + if flag := cmd.Flag("vault"); flag != nil && flag.Changed { + return nil, errors.New("--profile and --vault are mutually exclusive") + } + + cfg, path, found, err := loadProjectConfig() + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("--profile %q requires %s in the project root", name, ProjectConfigFile) + } + profile, ok := cfg.Profiles[name] + if !ok { + names := make([]string, 0, len(cfg.Profiles)) + for available := range cfg.Profiles { + names = append(names, available) + } + sort.Strings(names) + if len(names) == 0 { + return nil, fmt.Errorf("profile %q not found in %s (no profiles are configured)", name, path) + } + return nil, fmt.Errorf("profile %q not found in %s (available: %s)", name, path, strings.Join(names, ", ")) + } + + return &resolvedRunProfile{ + Address: cfg.Address, + Vault: profile.Vault, + Env: profile.Env, + }, nil +} + +func setResolvedRunProfileVault(cmd *cobra.Command, vault string) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[resolvedRunProfileVaultAnnotation] = vault +} + +func clearResolvedRunProfileVault(cmd *cobra.Command) { + delete(cmd.Annotations, resolvedRunProfileVaultAnnotation) +} + +func getResolvedRunProfileVault(cmd *cobra.Command) string { + if cmd.Annotations == nil { + return "" + } + return cmd.Annotations[resolvedRunProfileVaultAnnotation] +} + +// applyProfileEnv replaces inherited values with the profile's child-only +// values. Sorting keeps the resulting env deterministic for tests and logs +// produced by process supervisors without ever exposing values ourselves. +func applyProfileEnv(env []string, values map[string]string) []string { + if len(values) == 0 { + return env + } + keys := make(map[string]struct{}, len(values)) + ordered := make([]string, 0, len(values)) + for key := range values { + keys[key] = struct{}{} + ordered = append(ordered, key) + } + sort.Strings(ordered) + env = stripEnvKeys(env, keys) + for _, key := range ordered { + env = append(env, key+"="+values[key]) + } + return env +} diff --git a/cmd/project_config_test.go b/cmd/project_config_test.go new file mode 100644 index 0000000..0d0fcd9 --- /dev/null +++ b/cmd/project_config_test.go @@ -0,0 +1,309 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func withWorkingDirectory(t *testing.T, dir string) { + t.Helper() + original, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(original); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) +} + +func writeProjectConfig(t *testing.T, root, contents string) string { + t.Helper() + path := filepath.Join(root, ProjectConfigFile) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestFindProjectConfigWalksToRepositoryRoot(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + want := writeProjectConfig(t, root, `{"vault":"root-vault"}`) + nested := filepath.Join(root, "a", "b") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatal(err) + } + withWorkingDirectory(t, nested) + + got, found, err := findProjectConfig() + if err != nil { + t.Fatalf("findProjectConfig: %v", err) + } + if !found || got != want { + t.Fatalf("got path=%q found=%v, want path=%q found=true", got, found, want) + } +} + +func TestFindProjectConfigDoesNotCrossRepositoryRoot(t *testing.T) { + parent := t.TempDir() + writeProjectConfig(t, parent, `{"vault":"parent-vault"}`) + root := filepath.Join(parent, "repo") + nested := filepath.Join(root, "nested") + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatal(err) + } + withWorkingDirectory(t, nested) + + path, found, err := findProjectConfig() + if err != nil { + t.Fatalf("findProjectConfig: %v", err) + } + if found || path != "" { + t.Fatalf("got path=%q found=%v, want no config", path, found) + } +} + +func TestResolveRunProfile(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + writeProjectConfig(t, root, `{ + "version": 1, + "address": "https://vault.example.test/", + "profiles": { + "development": { + "vault": "app-dev", + "env": {"API_KEY": "__api_key__", "PUBLIC_URL": "https://app.example.test"} + } + } +}`) + nested := filepath.Join(root, "cmd", "worker") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatal(err) + } + withWorkingDirectory(t, nested) + + cmd := newRunCmdForTest() + if err := cmd.Flags().Set("profile", "development"); err != nil { + t.Fatal(err) + } + profile, err := resolveRunProfile(cmd) + if err != nil { + t.Fatalf("resolveRunProfile: %v", err) + } + if profile.Vault != "app-dev" { + t.Fatalf("unexpected profile: %#v", profile) + } + if profile.Address != "https://vault.example.test" { + t.Errorf("address=%q, want normalized address", profile.Address) + } + if profile.Env["API_KEY"] != "__api_key__" { + t.Errorf("profile env was not loaded") + } +} + +func TestResolveRunProfileRejectsVaultFlag(t *testing.T) { + cmd := newRunCmdForTest() + if err := cmd.Flags().Set("profile", "development"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("vault", "explicit"); err != nil { + t.Fatal(err) + } + _, err := resolveRunProfile(cmd) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("got err=%v, want mutually-exclusive error", err) + } +} + +func TestResolveRunProfileUnknownListsAvailableNames(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + writeProjectConfig(t, root, `{"version":1,"profiles":{"prod":{"vault":"prod"},"dev":{"vault":"dev"}}}`) + withWorkingDirectory(t, root) + + cmd := newRunCmdForTest() + if err := cmd.Flags().Set("profile", "missing"); err != nil { + t.Fatal(err) + } + _, err := resolveRunProfile(cmd) + if err == nil || !strings.Contains(err.Error(), "available: dev, prod") { + t.Fatalf("got err=%v, want sorted available profiles", err) + } +} + +func TestLoadProjectConfigRejectsReservedEnvWithoutLeakingValue(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o700); err != nil { + t.Fatal(err) + } + const sensitiveValue = "must-not-appear-in-errors" + writeProjectConfig(t, root, `{"version":1,"profiles":{"dev":{"vault":"dev","env":{"HTTPS_PROXY":"`+sensitiveValue+`"}}}}`) + withWorkingDirectory(t, root) + + _, _, _, err := loadProjectConfig() + if err == nil || !strings.Contains(err.Error(), "HTTPS_PROXY") { + t.Fatalf("got err=%v, want reserved-key error", err) + } + if strings.Contains(err.Error(), sensitiveValue) { + t.Fatal("validation error leaked an environment value") + } +} + +func TestValidateProjectConfigRejectsNULWithoutLeakingValue(t *testing.T) { + cfg := projectConfig{ + Version: 1, + Profiles: map[string]projectProfile{ + "dev": {Vault: "dev", Env: map[string]string{"API_KEY": "prefix\x00sensitive-suffix"}}, + }, + } + err := validateProjectConfig(&cfg) + if err == nil || !strings.Contains(err.Error(), "NUL") { + t.Fatalf("got err=%v, want NUL error", err) + } + if strings.Contains(err.Error(), "sensitive-suffix") { + t.Fatal("validation error leaked an environment value") + } +} + +func TestValidateProfileEnvKey(t *testing.T) { + for _, key := range []string{ + "AGENT_VAULT_TOKEN", + "https_proxy", + "ALL_PROXY", + "SSL_CERT_FILE", + "PATH", + "HOME", + "LD_PRELOAD", + "DYLD_INSERT_LIBRARIES", + } { + t.Run(key, func(t *testing.T) { + if err := validateProfileEnvKey(key); err == nil { + t.Fatalf("expected %s to be reserved", key) + } + }) + } + for _, key := range []string{"API_KEY", "APP_API_URL", "CLOUDFLARE_ZONE_ID"} { + t.Run(key, func(t *testing.T) { + if err := validateProfileEnvKey(key); err != nil { + t.Fatalf("expected %s to be allowed: %v", key, err) + } + }) + } +} + +func TestValidateProjectConfigVersionsAndAddress(t *testing.T) { + tests := []struct { + name string + cfg projectConfig + want string + }{ + {"legacy vault is valid", projectConfig{Vault: "legacy"}, ""}, + {"profiles require version", projectConfig{Profiles: map[string]projectProfile{"dev": {Vault: "dev"}}}, "require version 1"}, + {"future version rejected", projectConfig{Version: 2}, "unsupported version"}, + {"address path rejected", projectConfig{Version: 1, Address: "https://vault.example.test/control"}, "must not contain a path"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateProjectConfig(&tc.cfg) + if tc.want == "" && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("got err=%v, want substring %q", err, tc.want) + } + }) + } +} + +func TestBuildHostRunEnvProfileAndGeneratedPrecedence(t *testing.T) { + parent := []string{ + "API_KEY=parent-value", + "PUBLIC_URL=https://old.example.test", + "AGENT_VAULT_TOKEN=stale-token", + "UNRELATED=preserved", + } + profileEnv := map[string]string{ + "API_KEY": "__api_key__", + "PUBLIC_URL": "https://new.example.test", + } + env := envMap(buildHostRunEnv(parent, profileEnv, "fresh-token", "https://vault.example.test", "dev")) + + if env["API_KEY"] != "__api_key__" || env["PUBLIC_URL"] != "https://new.example.test" { + t.Errorf("profile values did not override parent values: %#v", env) + } + if env["AGENT_VAULT_TOKEN"] != "fresh-token" || env["AGENT_VAULT_VAULT"] != "dev" { + t.Errorf("generated Agent Vault values did not win: %#v", env) + } + if env["UNRELATED"] != "preserved" { + t.Error("unrelated inherited environment value was dropped") + } +} + +func TestBuildContainerRunEnvIncludesProfileAndGeneratedValues(t *testing.T) { + env := envMap(buildContainerRunEnv( + map[string]string{"API_KEY": "__api_key__"}, + "fresh-token", + "dev", + 14321, + 14322, + )) + if env["API_KEY"] != "__api_key__" { + t.Error("profile environment value was not passed to the container") + } + if env["AGENT_VAULT_TOKEN"] != "fresh-token" || env["AGENT_VAULT_VAULT"] != "dev" { + t.Errorf("generated Agent Vault values are missing: %#v", env) + } + if env["HTTPS_PROXY"] == "" || env["SSL_CERT_FILE"] == "" { + t.Error("generated proxy environment values are missing") + } +} + +func TestResolveSessionWithAddressSupportsAgentModeOverride(t *testing.T) { + t.Setenv("AGENT_VAULT_TOKEN", "test-token") + t.Setenv("AGENT_VAULT_ADDR", "") + + sess, source, err := resolveSessionWithAddress("https://vault.example.test/") + if err != nil { + t.Fatalf("resolveSessionWithAddress: %v", err) + } + if source != "AGENT_VAULT_TOKEN" || sess.Address != "https://vault.example.test" { + t.Fatalf("unexpected session=%#v source=%q", sess, source) + } +} + +func TestResolveRunAddressPrecedence(t *testing.T) { + t.Setenv("AGENT_VAULT_ADDR", "https://env.example.test") + + cmd := newRunCmdForTest() + if got := resolveRunAddress(cmd, "https://profile.example.test"); got != "https://env.example.test" { + t.Fatalf("env address=%q, want env address", got) + } + if err := cmd.Flags().Set("address", "https://flag.example.test"); err != nil { + t.Fatal(err) + } + if got := resolveRunAddress(cmd, "https://profile.example.test"); got != "https://flag.example.test" { + t.Fatalf("flag address=%q, want flag address", got) + } + + t.Setenv("AGENT_VAULT_ADDR", "") + cmd = newRunCmdForTest() + if got := resolveRunAddress(cmd, "https://profile.example.test"); got != "https://profile.example.test" { + t.Fatalf("profile address=%q, want profile address", got) + } +} diff --git a/cmd/run.go b/cmd/run.go index cd1da01..cfa3f75 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -6,7 +6,6 @@ import ( _ "embed" "encoding/json" "errors" - "time" "fmt" "net" "net/http" @@ -17,6 +16,7 @@ import ( "strconv" "strings" "syscall" + "time" "github.com/Infisical/agent-vault/internal/isolation" "github.com/Infisical/agent-vault/internal/session" @@ -45,8 +45,8 @@ Two modes: Agent mode: pre-supply a token via env. Used for unattended / containerized deployments where there's no human to interactively log in. AGENT_VAULT_TOKEN — vault-scoped session token or long-lived agent token - AGENT_VAULT_ADDR — broker base URL - AGENT_VAULT_VAULT — vault to scope the run to (required in agent mode for both token types) + AGENT_VAULT_ADDR — broker base URL (or select a profile with address) + AGENT_VAULT_VAULT — vault to scope the run to (or select a profile) The token is validated against the broker once before the child is exec'd. --ttl has no effect in agent mode and is rejected (the token's lifetime is fixed at mint time). @@ -56,6 +56,11 @@ Environment variables set on the child: AGENT_VAULT_ADDR — base URL of the Agent Vault HTTP control server AGENT_VAULT_VAULT — vault the session is scoped to +With --profile, agent-vault.json supplies the vault and optional child-only +environment values such as credential placeholders and public service URLs. +Profile values override inherited environment values; Agent Vault's generated +token, vault, address, proxy, and CA variables always take precedence. + The child also inherits HTTPS_PROXY / HTTP_PROXY / NO_PROXY / NODE_USE_ENV_PROXY / OPENCLAW_PROXY_URL plus the root CA trust variables (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, @@ -72,7 +77,8 @@ for http:// on the same port. The root CA PEM is written to Example: ` + examplePrefix + ` -- claude - ` + examplePrefix + ` --vault myproject -- claude`, + ` + examplePrefix + ` --vault myproject -- claude + ` + examplePrefix + ` --profile development -- claude`, Args: cobra.MinimumNArgs(1), DisableFlagsInUseLine: true, RunE: runCmdRunE, @@ -83,6 +89,7 @@ Example: c.Flags().String("address", "", "Agent Vault server address (defaults to session address)") c.Flags().Int("ttl", 0, "Session TTL in seconds (300–604800; default: server default 24h)") + c.Flags().String("profile", "", "project profile from agent-vault.json (sets vault and child environment)") c.Flags().String("image", "", "Container image override (requires --isolation=container)") c.Flags().StringArray("mount", nil, "Extra bind mount src:dst[:ro] (repeatable; requires --isolation=container)") @@ -98,6 +105,19 @@ var runCmd = newRunCmd("agent-vault vault run") var topRunCmd = newRunCmd("agent-vault run") func runCmdRunE(cmd *cobra.Command, args []string) error { + clearResolvedRunProfileVault(cmd) + profile, err := resolveRunProfile(cmd) + if err != nil { + return err + } + var profileEnv map[string]string + var profileAddress string + if profile != nil { + setResolvedRunProfileVault(cmd, profile.Vault) + profileEnv = profile.Env + profileAddress = profile.Address + } + // 0. Resolve isolation mode and validate flag compatibility before any // network I/O — the user sees conflicts immediately, not after // a slow session-mint round-trip. @@ -120,16 +140,18 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { // session (human mode). Agent mode is the path used by containerized // deployments where there's no TTY and no on-disk session. // tokenSource is "" in human mode; in agent mode it's envVarToken. - sess, tokenSource, err := resolveSession() + addr := resolveRunAddress(cmd, profileAddress) + + sess, tokenSource, err := resolveSessionWithAddress(addr) if err != nil { return err } fromEnv := tokenSource != "" - addr, _ := cmd.Flags().GetString("address") if addr == "" { addr = sess.Address } + addr = strings.TrimRight(addr, "/") // 2-3. Determine the token + vault. In agent mode the env-supplied // token IS the credential; we validate it once against the broker @@ -173,7 +195,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { tel.Close() if mode == IsolationContainer { - return runContainer(cmd, args, token, addr, vault) + return runContainer(cmd, args, token, addr, vault, profileEnv) } // 4. Resolve the target binary. @@ -189,13 +211,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { // silently shadow the freshly-injected one. Particularly important // in agent mode, where the parent env is *guaranteed* to carry these // keys (that's how agent mode is detected). - env := os.Environ() - env = stripEnvKeys(env, agentVaultInjectedKeys) - env = append(env, - "AGENT_VAULT_TOKEN="+token, - "AGENT_VAULT_ADDR="+addr, - "AGENT_VAULT_VAULT="+vault, - ) + env := buildHostRunEnv(os.Environ(), profileEnv, token, addr, vault) // 6. Route the child's HTTP and HTTPS traffic through the transparent // MITM proxy. The MITM ingress is the only credential-injection @@ -225,6 +241,26 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { return syscall.Exec(binary, args, env) //nolint:gosec } +func resolveRunAddress(cmd *cobra.Command, profileAddress string) string { + if addr, _ := cmd.Flags().GetString("address"); addr != "" { + return addr + } + if addr := os.Getenv("AGENT_VAULT_ADDR"); addr != "" { + return addr + } + return profileAddress +} + +func buildHostRunEnv(parent []string, profileEnv map[string]string, token, addr, vault string) []string { + env := applyProfileEnv(parent, profileEnv) + env = stripEnvKeys(env, agentVaultInjectedKeys) + return append(env, + "AGENT_VAULT_TOKEN="+token, + "AGENT_VAULT_ADDR="+addr, + "AGENT_VAULT_VAULT="+vault, + ) +} + // knownAgents maps CLI binary base-names to the (agentName, skillsDir) // pair used by maybeInstallSkills. Multiple base-names can map to the // same entry (e.g. "cursor" and "agent" both target ".cursor"). @@ -339,10 +375,13 @@ func maybeConfigureOpenClaw() { } // resolveVaultForAgentMode picks the vault when the token is supplied via env -// (agent / containerized run). No project-file or interactive-picker fallback -// — neither makes sense in an unattended container, and silently defaulting -// to "default" would mask misconfig. Priority: --vault > AGENT_VAULT_VAULT > error. +// (agent / containerized run). A resolved run profile takes priority, followed +// by --vault and AGENT_VAULT_VAULT. There is no interactive-picker fallback, +// because silently defaulting to "default" would mask unattended misconfig. func resolveVaultForAgentMode(cmd *cobra.Command) (string, error) { + if profileVault := getResolvedRunProfileVault(cmd); profileVault != "" { + return profileVault, nil + } if name, _ := cmd.Flags().GetString("vault"); name != "" { return name, nil } @@ -370,6 +409,9 @@ func resolveVaultForCommand(cmd *cobra.Command, tokenSource string) (string, err // resolveVaultForRun picks the vault for a run session. Priority: // --vault flag > project file > vault context > interactive select (if multiple) > "default". func resolveVaultForRun(cmd *cobra.Command, addr, token string) (string, error) { + if profileVault := getResolvedRunProfileVault(cmd); profileVault != "" { + return profileVault, nil + } // Explicit --vault flag takes priority. if name, _ := cmd.Flags().GetString("vault"); name != "" { return name, nil @@ -581,7 +623,6 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri return env, 0, false, fmt.Errorf("write CA: %w", err) } - env = stripEnvKeys(env, mitmInjectedKeys) env = append(env, isolation.BuildProxyEnv(isolation.ProxyEnvParams{ Host: resolveMITMHost(addr), diff --git a/cmd/run_container.go b/cmd/run_container.go index 316fe94..87e2fbc 100644 --- a/cmd/run_container.go +++ b/cmd/run_container.go @@ -53,7 +53,7 @@ func validateIsolationFlagConflicts(cmd *cobra.Command, mode IsolationMode) erro // runContainer launches the target agent inside a Docker container with // egress locked to the agent-vault proxy via iptables. -func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault string) error { +func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault string, profileEnv map[string]string) error { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { return fmt.Errorf("--isolation=container: only linux and darwin are supported in v1 (got %s)", runtime.GOOS) } @@ -197,7 +197,7 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st return fmt.Errorf("getwd: %w", err) } - env := isolation.BuildContainerEnv(scopedToken, vault, fwd.HTTPPort, fwd.MITMPort) + env := buildContainerRunEnv(profileEnv, scopedToken, vault, fwd.HTTPPort, fwd.MITMPort) mounts, _ := cmd.Flags().GetStringArray("mount") keep, _ := cmd.Flags().GetBool("keep") @@ -280,3 +280,8 @@ func runContainer(cmd *cobra.Command, args []string, scopedToken, addr, vault st } return nil } + +func buildContainerRunEnv(profileEnv map[string]string, token, vault string, httpPort, mitmPort int) []string { + env := applyProfileEnv(nil, profileEnv) + return append(env, isolation.BuildContainerEnv(token, vault, httpPort, mitmPort)...) +} diff --git a/cmd/run_test.go b/cmd/run_test.go index 3314f27..81b7d05 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -18,7 +18,7 @@ import ( // the other is a bug. `vault` is inherited from vaultCmd's persistent flags // on `vault run` and registered locally on the top-level `run`. var expectedRunFlags = []string{ - "address", "ttl", "vault", + "address", "ttl", "vault", "profile", "isolation", "image", "mount", "keep", "no-firewall", "home-volume-shared", "share-agent-dir", } @@ -59,6 +59,19 @@ func TestTopLevelRunRegistered(t *testing.T) { } } +func TestProfileCanSelectVaultWithInheritedFlag(t *testing.T) { + parent := &cobra.Command{Use: "vault"} + parent.PersistentFlags().String("vault", "", "") + child := newRunCmd("test") + parent.AddCommand(child) + + setResolvedRunProfileVault(child, "profile-vault") + got, err := resolveVaultForAgentMode(child) + if err != nil || got != "profile-vault" { + t.Fatalf("resolved vault=%q err=%v, want profile-vault", got, err) + } +} + func TestAugmentEnvWithMITM_Disabled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/mitm/ca.pem" { diff --git a/cmd/vault_init.go b/cmd/vault_init.go index c7597f1..1663e28 100644 --- a/cmd/vault_init.go +++ b/cmd/vault_init.go @@ -11,8 +11,8 @@ import ( var vaultInitCmd = &cobra.Command{ Use: "init", - Short: "Bind the current directory to a vault (writes agent-vault.json)", - Long: "Writes an agent-vault.json file in the current directory so all team members automatically target the same vault. The file is meant to be committed to version control.", + Short: "Bind the current project to a vault (writes agent-vault.json)", + Long: "Writes an agent-vault.json file in the current project so all team members automatically target the same vault. If a config is already discoverable from the current directory, it is updated in place. The file is meant to be committed to version control.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { client, err := ensureSession() @@ -29,14 +29,27 @@ var vaultInitCmd = &cobra.Command{ } } - // Check for existing file and confirm overwrite. - if data, err := os.ReadFile(ProjectConfigFile); err == nil { - var existing struct { - Vault string `json:"vault"` + // Check for existing file and confirm changing the legacy binding. + // Preserve versioned profile configuration when adding or updating the + // top-level vault field. + configPath := ProjectConfigFile + if path, found, err := findProjectConfig(); err != nil { + return err + } else if found { + configPath = path + } + + cfg := projectConfig{} + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("parsing existing %s: %w", configPath, err) + } + if err := validateProjectConfig(&cfg); err != nil { + return fmt.Errorf("validating existing %s: %w", configPath, err) } - if json.Unmarshal(data, &existing) == nil && existing.Vault != "" { - fmt.Fprintf(os.Stderr, "Current binding: vault %q\n", existing.Vault) - if existing.Vault == vaultName { + if cfg.Vault != "" { + fmt.Fprintf(os.Stderr, "Current binding: vault %q\n", cfg.Vault) + if cfg.Vault == vaultName { fmt.Fprintln(os.Stderr, "Already bound to this vault, nothing to do.") return nil } @@ -55,18 +68,18 @@ var vaultInitCmd = &cobra.Command{ } } - cfg := map[string]string{"vault": vaultName} + cfg.Vault = vaultName data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } data = append(data, '\n') - if err := os.WriteFile(ProjectConfigFile, data, 0o600); err != nil { - return fmt.Errorf("writing %s: %w", ProjectConfigFile, err) + if err := os.WriteFile(configPath, data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", configPath, err) } - fmt.Fprintf(os.Stderr, "%s Wrote %s (vault: %s)\n", successText("✓"), ProjectConfigFile, vaultName) + fmt.Fprintf(os.Stderr, "%s Wrote %s (vault: %s)\n", successText("✓"), configPath, vaultName) fmt.Fprintln(os.Stderr, "Commit this file so your team shares the vault binding.") return nil }, diff --git a/docs/learn/vaults.mdx b/docs/learn/vaults.mdx index b7b2a72..b83ed8e 100644 --- a/docs/learn/vaults.mdx +++ b/docs/learn/vaults.mdx @@ -34,10 +34,50 @@ Run `vault init` inside your project directory to create an `agent-vault.json` f agent-vault vault init ``` -This file is meant to be committed to version control. When present, all team members and agents running in that directory will automatically target the bound vault without needing `--vault` flags or per-user context. +This file is meant to be committed to version control. Agent Vault discovers it from the current directory upward, stopping at the repository root. When present, all team members and agents running anywhere in that repository automatically target the bound vault without needing `--vault` flags or per-user context. Vault resolution priority: `--vault` flag > `AGENT_VAULT_VAULT` env var > `agent-vault.json` > user context > `"default"`. +## Define project profiles + +Projects that use more than one vault can define named profiles in the same committed file. A profile selects a vault and supplies child-only environment values for credential placeholders and public configuration: + +```json +{ + "version": 1, + "address": "https://agent-vault.example.com", + "profiles": { + "development": { + "vault": "my-app-dev", + "env": { + "ANTHROPIC_API_KEY": "__anthropic_api_key__", + "APP_API_URL": "https://api.dev.example.com" + } + }, + "production": { + "vault": "my-app-prod", + "env": { + "ANTHROPIC_API_KEY": "__anthropic_api_key__", + "APP_API_URL": "https://api.example.com" + } + } + } +} +``` + +Select a profile when launching a child process: + +```bash +agent-vault run --profile development -- your-agent +agent-vault run --profile production -- your-command +``` + +`--profile` and `--vault` are mutually exclusive. The profile environment overrides inherited values only in the child process. Generated Agent Vault token, address, vault, proxy, and CA variables always win. Host and container isolation modes behave the same way. + +Never commit real credentials in `env`. Store them in Agent Vault and put only substitution placeholders or non-secret configuration in this file. Agent Vault rejects profile entries that could replace its own variables, proxy and CA variables, `PATH`, `HOME`, or dynamic-loader variables. + +When `address` is present, address resolution for `run` is `--address` > `AGENT_VAULT_ADDR` > profile config > logged-in session. This also lets unattended agent mode provide only `AGENT_VAULT_TOKEN` plus a selected profile. Treat changes to `address` as security-sensitive: the selected broker receives the Agent Vault token used by the run. + ## Add an agent to a vault For any agent that lives outside `vault run` (cloud-hosted, CI pipelines, always-on assistants), create a named agent and supply its token via `AGENT_VAULT_TOKEN`. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 0d6d1f6..d70c164 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -336,7 +336,7 @@ description: "Complete reference for all Agent Vault CLI commands." agent-vault vault init [flags] ``` - Bind the current directory to a vault by writing an `agent-vault.json` file. This file is meant to be committed to version control so the whole team shares the vault binding. + Bind the current project to a vault by writing an `agent-vault.json` file. This file is meant to be committed to version control so the whole team shares the vault binding. If a config is already discoverable from the current directory, it is updated in place and existing versioned profile configuration is preserved. Vault resolution priority: `--vault` flag > `AGENT_VAULT_VAULT` env var > `agent-vault.json` > user context > `"default"`. @@ -366,11 +366,14 @@ description: "Complete reference for all Agent Vault CLI commands." The child process receives `AGENT_VAULT_ADDR`, `AGENT_VAULT_TOKEN`, and `AGENT_VAULT_VAULT`, plus `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` / `NODE_USE_ENV_PROXY` / `OPENCLAW_PROXY_URL` and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) pointing at `~/.agent-vault/mitm-ca.pem`, so standard HTTP and HTTPS clients transparently route through the broker. `OPENCLAW_PROXY_URL` feeds OpenClaw's Proxyline managed proxy (OpenClaw requires this plus `proxy.enabled: true` in its config). `HTTPS_PROXY` and `HTTP_PROXY` both point at the same plain HTTP proxy URL — the listener handles CONNECT for `https://` upstreams and absolute-form forward-proxy requests for `http://` upstreams on the same port. If the server's MITM proxy is unreachable, `vault run` aborts. - **Agent mode (containerized / unattended deployments).** When `AGENT_VAULT_TOKEN` and `AGENT_VAULT_ADDR` are pre-set on the environment, `vault run` skips the admin-session login and uses the env-supplied token as the credential — `--ttl` is rejected in this mode since the token's lifetime is fixed at mint time. The token is validated against the broker once at startup so bad/expired tokens fail fast with a clear error rather than producing 401s on every proxied call. See [Deploy your agent in a container](/guides/deploy-agent-container). + **Agent mode (containerized / unattended deployments).** When `AGENT_VAULT_TOKEN` is pre-set and the address comes from `AGENT_VAULT_ADDR`, `--address`, or a selected project profile, `vault run` skips the admin-session login and uses the env-supplied token as the credential — `--ttl` is rejected in this mode since the token's lifetime is fixed at mint time. The token is validated against the broker once at startup so bad/expired tokens fail fast with a clear error rather than producing 401s on every proxied call. See [Deploy your agent in a container](/guides/deploy-agent-container). + + **Project profiles.** `--profile ` loads a named profile from the nearest `agent-vault.json` between the current directory and repository root. The profile selects a vault and may set child-only credential placeholders or public configuration. Profile values override inherited child environment values, while generated Agent Vault and proxy variables always take precedence. `--profile` and `--vault` are mutually exclusive. See [Vaults: Define project profiles](/learn/vaults#define-project-profiles). | Flag | Default | Description | |------|---------|-------------| | `--address` | | Server address override | + | `--profile` | | Named project profile from `agent-vault.json`; mutually exclusive with `--vault`. | | `--ttl` | `0` | Session TTL in seconds (300–604800). Default: server default (24h). | | `--isolation` | `host` | Isolation mode for the child: `host` (default, cooperative — runs on the host with `HTTPS_PROXY`/`HTTP_PROXY`) or `container` (non-cooperative Docker container; see [Container isolation](/guides/container-isolation)). Also read from `AGENT_VAULT_ISOLATION`. | | `--image` | | Override the bundled container image (`--isolation=container` only). |