From edd2e64d41edb397e000784964429c85ee064e1b Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:46:21 +0400 Subject: [PATCH 1/2] feat: add Bitwarden secrets integration --- README.md | 27 + cmd/obol/agent.go | 126 +++++ cmd/obol/agent_test.go | 43 +- cmd/obol/model.go | 51 +- cmd/obol/model_test.go | 10 + internal/agentcrd/agent_test.go | 2 +- internal/embed/embed_crd_test.go | 8 +- .../base/templates/agent-crd.yaml | 40 ++ .../skills/agent-factory/scripts/factory.py | 23 + internal/hermes/bitwarden.go | 468 ++++++++++++++++++ internal/hermes/bitwarden_test.go | 89 ++++ internal/hermes/hermes.go | 27 +- internal/hermes/hermes_test.go | 57 ++- internal/model/model.go | 9 +- internal/monetizeapi/types.go | 26 +- internal/serviceoffercontroller/agent.go | 12 + .../serviceoffercontroller/agent_render.go | 37 +- .../agent_render_test.go | 57 ++- internal/serviceoffercontroller/agent_test.go | 28 ++ .../bitwarden-secrets-integration-20260524.md | 367 ++++++++++++++ 20 files changed, 1481 insertions(+), 26 deletions(-) create mode 100644 internal/hermes/bitwarden.go create mode 100644 internal/hermes/bitwarden_test.go create mode 100644 plans/bitwarden-secrets-integration-20260524.md diff --git a/README.md b/README.md index 818f9f3f..2cd329a0 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,33 @@ obol openclaw dashboard Use `obol agent` for Obol-managed lifecycle and auth flows. Use `obol hermes` for native Hermes CLI commands against the default instance, or pass `--agent ` for a non-default Hermes instance. +### Bitwarden Secrets + +Hermes agents can sync runtime environment variables from Bitwarden Secrets +Manager. Obol stores only the Bitwarden bootstrap token in the agent namespace's +`hermes-env` Secret; non-secret metadata is kept in the agent deployment config. +The setup command validates project access with the Bitwarden `bws` CLI on the +host. + +```bash +obol agent secrets bitwarden setup obol-agent \ + --project-id \ + --access-token "$BWS_ACCESS_TOKEN" + +obol agent secrets bitwarden status obol-agent +``` + +Provider setup can then fetch the provider key from the configured Bitwarden +project, validate it, and write the active key to LiteLLM: + +```bash +obol model setup --provider openai --api-key-source bitwarden +``` + +Bitwarden secret names should match environment variable names such as +`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. This integration is Hermes-only; +OpenClaw runtimes are not supported. + ### Skills The stack ships with embedded Obol skills that are installed automatically for the default Hermes agent and for OpenClaw instances. Skills give the agent domain-specific capabilities — from querying blockchains to understanding Ethereum development patterns. diff --git a/cmd/obol/agent.go b/cmd/obol/agent.go index f3062f66..fb5315ce 100644 --- a/cmd/obol/agent.go +++ b/cmd/obol/agent.go @@ -241,6 +241,7 @@ Hermes/OpenClaw onboard flow used by the master agent.`, }, }, agentUpdateCommand(cfg), + agentSecretsCommand(cfg), agentWalletCommand(cfg), }, } @@ -594,6 +595,131 @@ func agentRuntimeFlag(value string) cli.Flag { } } +func agentSecretsCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "secrets", + Usage: "Manage agent runtime secret sources", + Commands: []*cli.Command{ + agentBitwardenSecretsCommand(cfg), + }, + } +} + +func agentBitwardenSecretsCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "bitwarden", + Usage: "Configure Hermes Bitwarden Secrets Manager sync", + Commands: []*cli.Command{ + { + Name: "setup", + Usage: "Enable Bitwarden Secrets Manager for a Hermes instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + agentRuntimeFlag("hermes"), + &cli.StringFlag{Name: "project-id", Usage: "Bitwarden Secrets Manager project ID", Required: true}, + &cli.StringFlag{Name: "server-url", Usage: "Bitwarden server URL", Value: "https://vault.bitwarden.com"}, + &cli.StringFlag{Name: "access-token", Usage: "Bitwarden machine-account access token", Sources: cli.EnvVars("BWS_ACCESS_TOKEN")}, + &cli.StringFlag{Name: "access-token-env", Usage: "Environment variable name Hermes reads for the bootstrap token", Value: "BWS_ACCESS_TOKEN"}, + &cli.IntFlag{Name: "cache-ttl", Usage: "Hermes Bitwarden cache TTL in seconds", Value: 300}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + target, err := resolveHermesBitwardenTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + token := cmd.String("access-token") + if strings.TrimSpace(token) == "" { + token, err = u.SecretInput("Bitwarden access token (BWS_ACCESS_TOKEN)") + if err != nil { + return err + } + } + return hermes.SetupBitwarden(cfg, target.ID, hermes.BitwardenSetupOptions{ + AccessToken: token, + ProjectID: cmd.String("project-id"), + ServerURL: cmd.String("server-url"), + AccessTokenEnv: cmd.String("access-token-env"), + CacheTTLSeconds: cmd.Int("cache-ttl"), + }, u) + }, + }, + { + Name: "status", + Usage: "Show Obol-managed Bitwarden config and Secret presence", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("hermes")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + target, err := resolveHermesBitwardenTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + status, err := hermes.GetBitwardenStatus(cfg, target.ID) + if err != nil { + return err + } + if u.IsJSON() { + return u.JSON(status) + } + u.Bold(fmt.Sprintf("Bitwarden secrets: hermes/%s", target.ID)) + u.Detail("Enabled", fmt.Sprint(status.Enabled)) + u.Detail("Project", emptyDisplay(status.ProjectID)) + u.Detail("Server", emptyDisplay(status.ServerURL)) + u.Detail("Access token env", status.AccessTokenEnv) + u.Detail("Metadata", status.MetadataPath) + u.Detail("Env Secret", boolPresent(status.EnvSecretExists)) + u.Detail("Token key", boolPresent(status.TokenKeyPresent)) + u.Detail("Server URL key", boolPresent(status.ServerURLPresent)) + return nil + }, + }, + { + Name: "disable", + Usage: "Disable Hermes Bitwarden config without deleting the env Secret", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("hermes")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveHermesBitwardenTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + return hermes.DisableBitwarden(cfg, target.ID, getUI(cmd)) + }, + }, + }, + } +} + +func resolveHermesBitwardenTarget(cfg *config.Config, runtimeValue string, args []string) (agentTarget, error) { + runtime, err := parseAgentRuntime(runtimeValue) + if err != nil { + return agentTarget{}, err + } + if runtime != agentruntime.Hermes { + return agentTarget{}, errors.New("Bitwarden secrets are supported for Hermes agents only; OpenClaw is not supported") + } + id, err := resolveRuntimeInstance(cfg, agentruntime.Hermes, args, true) + if err != nil { + return agentTarget{}, err + } + return agentTarget{Runtime: agentruntime.Hermes, ID: id}, nil +} + +func boolPresent(v bool) string { + if v { + return "present" + } + return "missing" +} + +func emptyDisplay(v string) string { + if strings.TrimSpace(v) == "" { + return "(unset)" + } + return v +} + func parseAgentRuntime(value string) (agentruntime.Runtime, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "hermes", "herme": diff --git a/cmd/obol/agent_test.go b/cmd/obol/agent_test.go index 43665ca7..8fbeb63f 100644 --- a/cmd/obol/agent_test.go +++ b/cmd/obol/agent_test.go @@ -14,14 +14,15 @@ func TestAgentCommand_Structure(t *testing.T) { cmd := agentCommand(cfg) expected := map[string]bool{ - "init": false, - "new": false, - "sync": false, - "setup": false, - "auth": false, - "list": false, - "delete": false, - "wallet": false, + "init": false, + "new": false, + "sync": false, + "setup": false, + "auth": false, + "list": false, + "delete": false, + "secrets": false, + "wallet": false, } for _, sub := range cmd.Commands { @@ -37,6 +38,32 @@ func TestAgentCommand_Structure(t *testing.T) { } } +func TestAgentSecretsCommand_ExposesBitwarden(t *testing.T) { + cfg := newTestConfig(t) + cmd := agentCommand(cfg) + secretsCmd := findSubcommand(t, cmd, "secrets") + bwCmd := findSubcommand(t, secretsCmd, "bitwarden") + + for _, name := range []string{"setup", "status", "disable"} { + findSubcommand(t, bwCmd, name) + } + + setup := findSubcommand(t, bwCmd, "setup") + flags := flagMap(setup) + requireFlags(t, flags, "runtime", "project-id", "server-url", "access-token", "access-token-env", "cache-ttl") + assertStringDefault(t, flags, "runtime", "hermes") + assertStringDefault(t, flags, "server-url", "https://vault.bitwarden.com") + assertStringDefault(t, flags, "access-token-env", "BWS_ACCESS_TOKEN") +} + +func TestResolveHermesBitwardenTargetRejectsOpenClaw(t *testing.T) { + cfg := newTestConfig(t) + _, err := resolveHermesBitwardenTarget(cfg, "openclaw", nil) + if err == nil || !strings.Contains(err.Error(), "OpenClaw is not supported") { + t.Fatalf("err = %v, want OpenClaw unsupported error", err) + } +} + func TestAgentNewCommand_DefaultsToHermes(t *testing.T) { cfg := newTestConfig(t) cmd := agentCommand(cfg) diff --git a/cmd/obol/model.go b/cmd/obol/model.go index f7c402e8..f64a9dcd 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/hermes" "github.com/ObolNetwork/obol-stack/internal/model" @@ -71,6 +72,15 @@ func modelSetupCommand(cfg *config.Config) *cli.Command { Usage: "API key for the provider", Sources: cli.EnvVars("LLM_API_KEY"), }, + &cli.StringFlag{ + Name: "api-key-source", + Usage: "API key source: bitwarden", + }, + &cli.StringFlag{ + Name: "agent", + Usage: "Hermes instance whose Bitwarden config supplies provider keys", + Value: agentruntime.DefaultInstanceID, + }, &cli.StringSliceFlag{ Name: "model", Usage: "Model(s) to configure (e.g. claude-sonnet-4-5-20250929, gpt-4o)", @@ -83,7 +93,14 @@ func modelSetupCommand(cfg *config.Config) *cli.Command { u := getUI(cmd) provider := cmd.String("provider") apiKey := cmd.String("api-key") + apiKeySource := strings.TrimSpace(cmd.String("api-key-source")) models := cmd.StringSlice("model") + if apiKeySource != "" && apiKeySource != "bitwarden" { + return fmt.Errorf("unsupported api-key-source %q (expected bitwarden)", apiKeySource) + } + if apiKeySource == "bitwarden" && strings.TrimSpace(apiKey) != "" { + return errors.New("--api-key and --api-key-source bitwarden are mutually exclusive") + } // Interactive mode if flags not provided if provider == "" { @@ -108,7 +125,7 @@ func modelSetupCommand(cfg *config.Config) *cli.Command { provider = providers[idx].ID // If a credential was detected for the chosen provider, offer to use it - if det, ok := creds[provider]; ok && det.key != "" && apiKey == "" { + if det, ok := creds[provider]; ok && det.key != "" && apiKey == "" && apiKeySource == "" { u.Infof("%s API key detected (%s)", providers[idx].Name, det.source) if u.Confirm("Use detected credential?", true) { @@ -120,8 +137,18 @@ func modelSetupCommand(cfg *config.Config) *cli.Command { // Provider-specific flow switch provider { case "ollama": + if apiKeySource == "bitwarden" { + return errors.New("ollama does not use an API key; --api-key-source bitwarden is not applicable") + } return setupOllama(cfg, u, models) case "anthropic", "openai": + if apiKeySource == "bitwarden" { + var err error + apiKey, err = readProviderKeyFromBitwarden(ctx, cfg, u, cmd.String("agent"), provider) + if err != nil { + return err + } + } return setupCloudProvider(cfg, u, provider, apiKey, models) default: return fmt.Errorf("unknown provider %q — use anthropic, openai, or ollama", provider) @@ -130,6 +157,28 @@ func modelSetupCommand(cfg *config.Config) *cli.Command { } } +func readProviderKeyFromBitwarden(ctx context.Context, cfg *config.Config, u *ui.UI, agentID, provider string) (string, error) { + secretName := model.ProviderEnvVar(provider) + if strings.TrimSpace(secretName) == "" { + return "", fmt.Errorf("provider %q does not use an API key", provider) + } + if strings.TrimSpace(agentID) == "" { + agentID = agentruntime.DefaultInstanceID + } + u.Infof("Reading %s from Bitwarden via hermes/%s", secretName, agentID) + fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + key, err := hermes.FetchBitwardenSecretForAgent(fetchCtx, cfg, agentID, secretName) + if err != nil { + return "", err + } + if strings.TrimSpace(key) == "" { + return "", fmt.Errorf("Bitwarden secret %q is empty", secretName) + } + u.Successf("Fetched %s from Bitwarden", secretName) + return key, nil +} + func setupOllama(cfg *config.Config, u *ui.UI, models []string) error { if len(models) == 0 { // Diagnostic: check Ollama connectivity diff --git a/cmd/obol/model_test.go b/cmd/obol/model_test.go index 7bf51882..760e054f 100644 --- a/cmd/obol/model_test.go +++ b/cmd/obol/model_test.go @@ -36,3 +36,13 @@ func TestModelCommand_Structure(t *testing.T) { } } } + +func TestModelSetupCommand_ExposesBitwardenKeySource(t *testing.T) { + cfg := &config.Config{} + cmd := modelCommand(cfg) + setup := findSubcommand(t, cmd, "setup") + flags := flagMap(setup) + + requireFlags(t, flags, "api-key-source", "agent") + assertStringDefault(t, flags, "agent", "obol-agent") +} diff --git a/internal/agentcrd/agent_test.go b/internal/agentcrd/agent_test.go index a2aa5973..624a0dec 100644 --- a/internal/agentcrd/agent_test.go +++ b/internal/agentcrd/agent_test.go @@ -74,7 +74,7 @@ func TestBuildAgent_OmitsEmpties(t *testing.T) { t.Errorf("runtime = %v, want hermes", spec["runtime"]) } // Empties should not be present so YAML stays small + diffs clean. - for _, k := range []string{"model", "objective", "wallet"} { + for _, k := range []string{"model", "objective", "wallet", "secrets"} { if _, ok := spec[k]; ok { t.Errorf("spec.%s set despite empty input: %v", k, spec[k]) } diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index 2fca04f6..eea9027f 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -419,11 +419,17 @@ func TestAgentCRD_Fields(t *testing.T) { if !ok { t.Fatal("spec.properties not a map") } - for _, field := range []string{"runtime", "model", "skills", "objective", "wallet"} { + for _, field := range []string{"runtime", "model", "skills", "objective", "wallet", "secrets"} { if _, exists := specProps[field]; !exists { t.Errorf("spec.properties missing %q", field) } } + if nested(specProps, "secrets", "properties", "bitwarden", "properties", "projectID") == nil { + t.Error("spec.secrets.bitwarden.projectID missing") + } + if nested(specProps, "secrets", "properties", "bitwarden", "properties", "accessTokenKey") == nil { + t.Error("spec.secrets.bitwarden.accessTokenKey missing") + } statusProps, ok := nested(v0, "schema", "openAPIV3Schema", "properties", "status", "properties").(map[string]any) if !ok { diff --git a/internal/embed/infrastructure/base/templates/agent-crd.yaml b/internal/embed/infrastructure/base/templates/agent-crd.yaml index 510d4d0c..d27e62b4 100644 --- a/internal/embed/infrastructure/base/templates/agent-crd.yaml +++ b/internal/embed/infrastructure/base/templates/agent-crd.yaml @@ -79,6 +79,46 @@ spec: type: boolean default: false description: "Provision a per-namespace remote-signer keystore. Address is published in status.walletAddress." + secrets: + type: object + properties: + bitwarden: + type: object + description: "Optional Hermes Bitwarden Secrets Manager startup sync." + properties: + enabled: + type: boolean + default: false + projectID: + type: string + maxLength: 128 + description: "Bitwarden Secrets Manager project UUID." + serverURL: + type: string + maxLength: 512 + description: "Optional Bitwarden server URL, e.g. https://vault.bitwarden.com." + accessTokenSecretName: + type: string + default: hermes-env + enum: + - hermes-env + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" + maxLength: 253 + accessTokenKey: + type: string + default: BWS_ACCESS_TOKEN + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + maxLength: 128 + overrideExisting: + type: boolean + default: true + cacheTTLSeconds: + type: integer + minimum: 0 + default: 300 + autoInstall: + type: boolean + default: true status: type: object properties: diff --git a/internal/embed/skills/agent-factory/scripts/factory.py b/internal/embed/skills/agent-factory/scripts/factory.py index b082a588..f5166974 100644 --- a/internal/embed/skills/agent-factory/scripts/factory.py +++ b/internal/embed/skills/agent-factory/scripts/factory.py @@ -308,6 +308,19 @@ def agent_resource(args, parent_ns): spec["objective"] = args.objective.strip() if args.create_wallet: spec["wallet"] = {"create": True} + if args.bitwarden_project_id: + bitwarden = { + "enabled": True, + "projectID": args.bitwarden_project_id, + "accessTokenSecretName": ENV_SECRET, + "accessTokenKey": args.bitwarden_access_token_env, + "cacheTTLSeconds": args.bitwarden_cache_ttl, + "overrideExisting": not args.bitwarden_no_override_existing, + "autoInstall": True, + } + if args.bitwarden_server_url: + bitwarden["serverURL"] = args.bitwarden_server_url + spec["secrets"] = {"bitwarden": bitwarden} return { "apiVersion": f"{CRD_GROUP}/{CRD_VERSION}", "kind": "Agent", @@ -403,6 +416,11 @@ def cmd_create(args, token, parent_ns, ssl_ctx): validate_positive_decimal(args.price, "--price") if args.pay_to and not ADDR_RE.match(args.pay_to): raise ValueError("--pay-to must be a 0x-prefixed EVM address") + if args.bitwarden_project_id: + if args.bitwarden_access_token_env not in env: + raise ValueError(f"--bitwarden-project-id requires --env {args.bitwarden_access_token_env}=") + if args.bitwarden_cache_ttl < 0: + raise ValueError("--bitwarden-cache-ttl must be >= 0") ns = namespace_for(args.name) apply_resource("/api/v1/namespaces", ns, namespace_resource(args.name, parent_ns), token, ssl_ctx) @@ -547,6 +565,11 @@ def build_parser(): create.add_argument("--profile-archive", help="Use an existing Hermes profile export tar.gz") create.add_argument("--create-wallet", action="store_true") create.add_argument("--env", action="append", default=[], help="Child env Secret entry KEY=VALUE") + create.add_argument("--bitwarden-project-id", help="Enable Hermes Bitwarden secret sync for this child Agent") + create.add_argument("--bitwarden-server-url", default="https://vault.bitwarden.com") + create.add_argument("--bitwarden-access-token-env", default="BWS_ACCESS_TOKEN") + create.add_argument("--bitwarden-cache-ttl", type=int, default=300) + create.add_argument("--bitwarden-no-override-existing", action="store_true") create.add_argument("--price", help="USDC per-request price; creates ServiceOffer when set") create.add_argument("--pay-to", help="Payment recipient wallet") create.add_argument("--network", default="base-sepolia") diff --git a/internal/hermes/bitwarden.go b/internal/hermes/bitwarden.go new file mode 100644 index 00000000..f67b81a9 --- /dev/null +++ b/internal/hermes/bitwarden.go @@ -0,0 +1,468 @@ +package hermes + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/agentruntime" + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/ui" + "gopkg.in/yaml.v3" +) + +const ( + bitwardenConfigFileName = "bitwarden.yaml" + bitwardenEnvSecretName = "hermes-env" + defaultBitwardenTokenEnv = "BWS_ACCESS_TOKEN" + defaultBitwardenCacheTTL = 300 + defaultBitwardenServerEnv = "BWS_SERVER_URL" + defaultBitwardenCommandName = "bws" +) + +// BitwardenConfig is Obol's non-secret metadata for Hermes' native +// secrets.bitwarden config block. The bootstrap token itself lives only in the +// per-agent hermes-env Secret. +type BitwardenConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ProjectID string `yaml:"project_id" json:"project_id"` + ServerURL string `yaml:"server_url,omitempty" json:"server_url,omitempty"` + AccessTokenEnv string `yaml:"access_token_env" json:"access_token_env"` + CacheTTLSeconds int `yaml:"cache_ttl_seconds" json:"cache_ttl_seconds"` + OverrideExisting bool `yaml:"override_existing" json:"override_existing"` + AutoInstall bool `yaml:"auto_install" json:"auto_install"` +} + +type BitwardenSetupOptions struct { + AccessToken string + ProjectID string + ServerURL string + AccessTokenEnv string + CacheTTLSeconds int +} + +type BitwardenStatus struct { + Enabled bool `json:"enabled"` + ProjectID string `json:"project_id,omitempty"` + ServerURL string `json:"server_url,omitempty"` + AccessTokenEnv string `json:"access_token_env"` + CacheTTLSeconds int `json:"cache_ttl_seconds"` + OverrideExisting bool `json:"override_existing"` + AutoInstall bool `json:"auto_install"` + MetadataPath string `json:"metadata_path"` + EnvSecretExists bool `json:"env_secret_exists"` + TokenKeyPresent bool `json:"token_key_present"` + ServerURLPresent bool `json:"server_url_present"` +} + +type bitwardenSecret struct { + Key string `json:"key"` + Value string `json:"value"` +} + +func DefaultBitwardenConfig() BitwardenConfig { + return BitwardenConfig{ + AccessTokenEnv: defaultBitwardenTokenEnv, + CacheTTLSeconds: defaultBitwardenCacheTTL, + OverrideExisting: true, + AutoInstall: true, + } +} + +func (c BitwardenConfig) normalized() BitwardenConfig { + def := DefaultBitwardenConfig() + if strings.TrimSpace(c.AccessTokenEnv) == "" { + c.AccessTokenEnv = def.AccessTokenEnv + } + if c.CacheTTLSeconds <= 0 { + c.CacheTTLSeconds = def.CacheTTLSeconds + } + // Metadata written by this implementation always includes these booleans. + // If an older/incomplete file omits them, prefer the Hermes defaults. + if !c.OverrideExisting { + c.OverrideExisting = def.OverrideExisting + } + if !c.AutoInstall { + c.AutoInstall = def.AutoInstall + } + return c +} + +func bitwardenConfigPath(deploymentDir string) string { + return filepath.Join(deploymentDir, bitwardenConfigFileName) +} + +func LoadBitwardenConfig(deploymentDir string) (BitwardenConfig, bool, error) { + path := bitwardenConfigPath(deploymentDir) + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return DefaultBitwardenConfig(), false, nil + } + return BitwardenConfig{}, false, fmt.Errorf("read Bitwarden config: %w", err) + } + var cfg BitwardenConfig + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return BitwardenConfig{}, false, fmt.Errorf("parse Bitwarden config: %w", err) + } + return cfg.normalized(), true, nil +} + +func saveBitwardenConfig(deploymentDir string, cfg BitwardenConfig) error { + if err := os.MkdirAll(deploymentDir, 0o755); err != nil { + return fmt.Errorf("create deployment dir: %w", err) + } + raw, err := yaml.Marshal(cfg.normalized()) + if err != nil { + return fmt.Errorf("marshal Bitwarden config: %w", err) + } + if err := os.WriteFile(bitwardenConfigPath(deploymentDir), raw, 0o600); err != nil { + return fmt.Errorf("write Bitwarden config: %w", err) + } + return nil +} + +func SetupBitwarden(cfg *config.Config, id string, opts BitwardenSetupOptions, u *ui.UI) error { + deploymentDir := DeploymentPath(cfg, id) + if _, err := os.Stat(deploymentDir); os.IsNotExist(err) { + return fmt.Errorf("deployment not found: hermes/%s", id) + } + + bw := DefaultBitwardenConfig() + bw.Enabled = true + bw.ProjectID = strings.TrimSpace(opts.ProjectID) + bw.ServerURL = strings.TrimSpace(opts.ServerURL) + if strings.TrimSpace(opts.AccessTokenEnv) != "" { + bw.AccessTokenEnv = strings.TrimSpace(opts.AccessTokenEnv) + } + if opts.CacheTTLSeconds > 0 { + bw.CacheTTLSeconds = opts.CacheTTLSeconds + } + + token := strings.TrimSpace(opts.AccessToken) + if token == "" { + return errors.New("Bitwarden access token is required") + } + if bw.ProjectID == "" { + return errors.New("Bitwarden project ID is required") + } + if err := validateBitwardenConfig(bw); err != nil { + return err + } + + u.Info("Validating Bitwarden project access") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + secrets, err := FetchBitwardenSecrets(ctx, bw, token) + if err != nil { + return err + } + u.Successf("Validated Bitwarden project (%d secret(s) readable)", len(secrets)) + + if err := saveBitwardenConfig(deploymentDir, bw); err != nil { + return err + } + + u.Info("Syncing Hermes deployment") + if err := Sync(cfg, id, u); err != nil { + return err + } + + if err := applyBitwardenEnvSecret(cfg, id, bw, token); err != nil { + return err + } + u.Successf("Updated %s/%s", agentruntime.Namespace(agentruntime.Hermes, id), bitwardenEnvSecretName) + + return restartHermesDeployment(cfg, id, u) +} + +func DisableBitwarden(cfg *config.Config, id string, u *ui.UI) error { + deploymentDir := DeploymentPath(cfg, id) + bw, _, err := LoadBitwardenConfig(deploymentDir) + if err != nil { + return err + } + bw.Enabled = false + if err := saveBitwardenConfig(deploymentDir, bw); err != nil { + return err + } + if err := Sync(cfg, id, u); err != nil { + return err + } + return restartHermesDeployment(cfg, id, u) +} + +func GetBitwardenStatus(cfg *config.Config, id string) (BitwardenStatus, error) { + deploymentDir := DeploymentPath(cfg, id) + bw, _, err := LoadBitwardenConfig(deploymentDir) + if err != nil { + return BitwardenStatus{}, err + } + exists, hasToken, hasServer, err := bitwardenEnvSecretPresence(cfg, id, bw.AccessTokenEnv) + if err != nil { + return BitwardenStatus{}, err + } + return BitwardenStatus{ + Enabled: bw.Enabled, + ProjectID: bw.ProjectID, + ServerURL: bw.ServerURL, + AccessTokenEnv: bw.AccessTokenEnv, + CacheTTLSeconds: bw.CacheTTLSeconds, + OverrideExisting: bw.OverrideExisting, + AutoInstall: bw.AutoInstall, + MetadataPath: bitwardenConfigPath(deploymentDir), + EnvSecretExists: exists, + TokenKeyPresent: hasToken, + ServerURLPresent: hasServer, + }, nil +} + +func FetchBitwardenSecretForAgent(ctx context.Context, cfg *config.Config, id, key string) (string, error) { + deploymentDir := DeploymentPath(cfg, id) + bw, _, err := LoadBitwardenConfig(deploymentDir) + if err != nil { + return "", err + } + if !bw.Enabled { + return "", fmt.Errorf("Bitwarden is not enabled for hermes/%s", id) + } + if strings.TrimSpace(bw.ProjectID) == "" { + return "", fmt.Errorf("Bitwarden project ID is not configured for hermes/%s", id) + } + if err := validateBitwardenConfig(bw); err != nil { + return "", err + } + token, err := readBitwardenBootstrapToken(cfg, id, bw.AccessTokenEnv) + if err != nil { + return "", err + } + secrets, err := FetchBitwardenSecrets(ctx, bw, token) + if err != nil { + return "", err + } + value := secrets[key] + if value == "" { + return "", fmt.Errorf("Bitwarden secret %q not found in configured project", key) + } + return value, nil +} + +func FetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string) (map[string]string, error) { + bw = bw.normalized() + token = strings.TrimSpace(token) + if token == "" { + return nil, errors.New("Bitwarden access token is required") + } + if strings.TrimSpace(bw.ProjectID) == "" { + return nil, errors.New("Bitwarden project ID is required") + } + if err := validateBitwardenConfig(bw); err != nil { + return nil, err + } + + bin := strings.TrimSpace(os.Getenv("OBOL_BWS_BIN")) + if bin == "" { + bin = defaultBitwardenCommandName + } + + cmd := exec.CommandContext(ctx, bin, "secret", "list", bw.ProjectID, "--output", "json") + cmd.Env = append(os.Environ(), + defaultBitwardenTokenEnv+"="+token, + bw.AccessTokenEnv+"="+token, + ) + if strings.TrimSpace(bw.ServerURL) != "" { + cmd.Env = append(cmd.Env, defaultBitwardenServerEnv+"="+strings.TrimSpace(bw.ServerURL)) + } + + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(redactBitwardenToken(string(out), token)) + if msg != "" { + return nil, fmt.Errorf("bws secret list failed: %w: %s", err, msg) + } + return nil, fmt.Errorf("bws secret list failed: %w", err) + } + + items, err := parseBitwardenSecretList(out) + if err != nil { + return nil, err + } + secrets := make(map[string]string, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Key) == "" { + continue + } + secrets[item.Key] = item.Value + } + return secrets, nil +} + +func validateBitwardenConfig(bw BitwardenConfig) error { + if strings.TrimSpace(bw.AccessTokenEnv) == "" { + return errors.New("Bitwarden access token env var is required") + } + if strings.ContainsAny(bw.AccessTokenEnv, " \t\r\n=") { + return fmt.Errorf("invalid Bitwarden access token env var %q", bw.AccessTokenEnv) + } + if strings.TrimSpace(bw.ServerURL) != "" { + u, err := url.Parse(strings.TrimSpace(bw.ServerURL)) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid Bitwarden server URL %q", bw.ServerURL) + } + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("invalid Bitwarden server URL scheme %q", u.Scheme) + } + } + return nil +} + +func parseBitwardenSecretList(raw []byte) ([]bitwardenSecret, error) { + var items []bitwardenSecret + if err := json.Unmarshal(raw, &items); err == nil { + return items, nil + } + var wrapped struct { + Data []bitwardenSecret `json:"data"` + Secrets []bitwardenSecret `json:"secrets"` + Items []bitwardenSecret `json:"items"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return nil, fmt.Errorf("parse bws secret list output: %w", err) + } + switch { + case wrapped.Data != nil: + return wrapped.Data, nil + case wrapped.Secrets != nil: + return wrapped.Secrets, nil + default: + return wrapped.Items, nil + } +} + +func applyBitwardenEnvSecret(cfg *config.Config, id string, bw BitwardenConfig, token string) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + ns := agentruntime.Namespace(agentruntime.Hermes, id) + stringData := map[string]string{bw.AccessTokenEnv: token} + if strings.TrimSpace(bw.ServerURL) != "" { + stringData[defaultBitwardenServerEnv] = strings.TrimSpace(bw.ServerURL) + } + manifest := map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": bitwardenEnvSecretName, + "namespace": ns, + "labels": map[string]string{ + "app.kubernetes.io/name": "hermes", + "app.kubernetes.io/managed-by": "obol", + }, + }, + "type": "Opaque", + "stringData": stringData, + } + raw, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshal Bitwarden env Secret: %w", err) + } + bin, kc := kubectl.Paths(cfg) + if _, err := kubectl.ApplyOutput(bin, kc, raw); err != nil { + return fmt.Errorf("apply Bitwarden env Secret: %w", err) + } + return nil +} + +func bitwardenEnvSecretPresence(cfg *config.Config, id, tokenKey string) (exists, hasToken, hasServer bool, err error) { + if err := kubectl.EnsureCluster(cfg); err != nil { + return false, false, false, err + } + ns := agentruntime.Namespace(agentruntime.Hermes, id) + bin, kc := kubectl.Paths(cfg) + raw, err := kubectl.Output(bin, kc, "get", "secret", bitwardenEnvSecretName, "-n", ns, "-o", "json", "--ignore-not-found") + if err != nil { + return false, false, false, err + } + if strings.TrimSpace(raw) == "" { + return false, false, false, nil + } + data, err := parseSecretData([]byte(raw)) + if err != nil { + return false, false, false, err + } + return true, strings.TrimSpace(data[tokenKey]) != "", strings.TrimSpace(data[defaultBitwardenServerEnv]) != "", nil +} + +func readBitwardenBootstrapToken(cfg *config.Config, id, tokenKey string) (string, error) { + if err := kubectl.EnsureCluster(cfg); err != nil { + return "", err + } + ns := agentruntime.Namespace(agentruntime.Hermes, id) + bin, kc := kubectl.Paths(cfg) + raw, err := kubectl.Output(bin, kc, "get", "secret", bitwardenEnvSecretName, "-n", ns, "-o", "json") + if err != nil { + return "", fmt.Errorf("read %s/%s: %w", ns, bitwardenEnvSecretName, err) + } + data, err := parseSecretData([]byte(raw)) + if err != nil { + return "", err + } + token := strings.TrimSpace(data[tokenKey]) + if token == "" { + return "", fmt.Errorf("%s/%s is missing %s", ns, bitwardenEnvSecretName, tokenKey) + } + return token, nil +} + +func parseSecretData(raw []byte) (map[string]string, error) { + var secret struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(raw, &secret); err != nil { + return nil, fmt.Errorf("parse Secret JSON: %w", err) + } + out := make(map[string]string, len(secret.Data)) + for key, value := range secret.Data { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + out[key] = value + continue + } + out[key] = string(decoded) + } + return out, nil +} + +func restartHermesDeployment(cfg *config.Config, id string, u *ui.UI) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + ns := agentruntime.Namespace(agentruntime.Hermes, id) + bin, kc := kubectl.Paths(cfg) + u.Info("Restarting Hermes") + if err := kubectl.Run(bin, kc, "rollout", "restart", "deployment/hermes", "-n", ns); err != nil { + return fmt.Errorf("restart Hermes: %w", err) + } + if err := kubectl.Run(bin, kc, "rollout", "status", "deployment/hermes", "-n", ns, "--timeout=120s"); err != nil { + return fmt.Errorf("Hermes rollout not confirmed: %w", err) + } + u.Success("Hermes restarted") + return nil +} + +func redactBitwardenToken(value, token string) string { + token = strings.TrimSpace(token) + if token == "" { + return value + } + return strings.ReplaceAll(value, token, "[REDACTED]") +} diff --git a/internal/hermes/bitwarden_test.go b/internal/hermes/bitwarden_test.go new file mode 100644 index 00000000..86d5fdd7 --- /dev/null +++ b/internal/hermes/bitwarden_test.go @@ -0,0 +1,89 @@ +package hermes + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBitwardenConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + cfg := DefaultBitwardenConfig() + cfg.Enabled = true + cfg.ProjectID = "project-123" + cfg.ServerURL = "https://vault.bitwarden.com" + + if err := saveBitwardenConfig(dir, cfg); err != nil { + t.Fatalf("saveBitwardenConfig: %v", err) + } + got, ok, err := LoadBitwardenConfig(dir) + if err != nil { + t.Fatalf("LoadBitwardenConfig: %v", err) + } + if !ok { + t.Fatal("LoadBitwardenConfig ok=false, want true") + } + if got.ProjectID != cfg.ProjectID || got.ServerURL != cfg.ServerURL || !got.Enabled { + t.Fatalf("loaded config = %#v", got) + } + if got.AccessTokenEnv != "BWS_ACCESS_TOKEN" { + t.Fatalf("AccessTokenEnv = %q", got.AccessTokenEnv) + } +} + +func TestFetchBitwardenSecretsUsesBWSCLI(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "bws") + if err := os.WriteFile(script, []byte(`#!/bin/sh +if [ "$1" != "secret" ] || [ "$2" != "list" ] || [ "$3" != "project-123" ]; then + echo "unexpected args: $*" >&2 + exit 2 +fi +if [ "$BWS_ACCESS_TOKEN" != "token-123" ]; then + echo "missing token" >&2 + exit 3 +fi +printf '[{"key":"OPENAI_API_KEY","value":"sk-test"}]' +`), 0o755); err != nil { + t.Fatalf("write fake bws: %v", err) + } + t.Setenv("OBOL_BWS_BIN", script) + + cfg := DefaultBitwardenConfig() + cfg.Enabled = true + cfg.ProjectID = "project-123" + secrets, err := FetchBitwardenSecrets(context.Background(), cfg, "token-123") + if err != nil { + t.Fatalf("FetchBitwardenSecrets: %v", err) + } + if got := secrets["OPENAI_API_KEY"]; got != "sk-test" { + t.Fatalf("OPENAI_API_KEY = %q", got) + } +} + +func TestFetchBitwardenSecretsRedactsTokenOnError(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "bws") + if err := os.WriteFile(script, []byte(`#!/bin/sh +echo "bad token token-123" >&2 +exit 1 +`), 0o755); err != nil { + t.Fatalf("write fake bws: %v", err) + } + t.Setenv("OBOL_BWS_BIN", script) + + cfg := DefaultBitwardenConfig() + cfg.ProjectID = "project-123" + _, err := FetchBitwardenSecrets(context.Background(), cfg, "token-123") + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "token-123") { + t.Fatalf("error leaked token: %v", err) + } + if !strings.Contains(err.Error(), "[REDACTED]") { + t.Fatalf("error missing redaction marker: %v", err) + } +} diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index 5774330b..959896fd 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -641,7 +641,11 @@ func writeDeploymentFiles(cfg *config.Config, id, deploymentDir, agentBaseURL st namespace := agentruntime.Namespace(agentruntime.Hermes, id) hostname := agentruntime.Hostname(agentruntime.Hermes, id) dashboardHost := dashboardHostname(id) - configData, err := generateConfig(cfg, primary) + bw, _, err := LoadBitwardenConfig(deploymentDir) + if err != nil { + return err + } + configData, err := generateConfig(cfg, primary, bw) if err != nil { return err } @@ -852,6 +856,11 @@ func generateValues(namespace, hostname, dashboardHostname, agentBaseURL, token, if agentBaseURL != "" { fmt.Fprintf(&b, " - name: AGENT_BASE_URL\n value: %s\n", quoteYAML(agentBaseURL)) } + fmt.Fprintf(&b, ` envFrom: + - secretRef: + name: %s + optional: true +`, bitwardenEnvSecretName) fmt.Fprintf(&b, ` readinessProbe: httpGet: @@ -1068,7 +1077,7 @@ func configuredModels(cfg *config.Config, u *ui.UI) ([]string, string, error) { return names, primary, nil } -func generateConfig(cfg *config.Config, primary string) ([]byte, error) { +func generateConfig(cfg *config.Config, primary string, bw BitwardenConfig) ([]byte, error) { payload := map[string]any{ "model": map[string]any{ "default": primary, @@ -1087,6 +1096,20 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { "external_dirs": []string{"/data/.hermes/" + obolSkillsDirName}, }, } + bw = bw.normalized() + if bw.Enabled { + payload["secrets"] = map[string]any{ + "bitwarden": map[string]any{ + "enabled": true, + "access_token_env": bw.AccessTokenEnv, + "project_id": bw.ProjectID, + "server_url": bw.ServerURL, + "cache_ttl_seconds": bw.CacheTTLSeconds, + "override_existing": bw.OverrideExisting, + "auto_install": bw.AutoInstall, + }, + } + } return yaml.Marshal(payload) } diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 2fa09d1a..4b7a9fb2 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -50,7 +50,7 @@ func TestGenerateConfig_PrimaryIsRoundTrippable(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - raw, err := generateConfig(testConfig(t), tc.primary) + raw, err := generateConfig(testConfig(t), tc.primary, DefaultBitwardenConfig()) if err != nil { t.Fatalf("generateConfig: %v", err) } @@ -70,7 +70,7 @@ func TestGenerateConfig_PrimaryIsRoundTrippable(t *testing.T) { } func TestGenerateConfig_UsesLiteLLMCustomProvider(t *testing.T) { - raw, err := generateConfig(testConfig(t), "gpt-5.2") + raw, err := generateConfig(testConfig(t), "gpt-5.2", DefaultBitwardenConfig()) if err != nil { t.Fatalf("generateConfig() error = %v", err) } @@ -140,6 +140,8 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { `value: "hermes-obol-agent"`, "OBOL_SKILLS_DIR", "/data/.hermes/obol-skills", + "name: hermes-env", + "optional: true", "containerPort: 8642", "containerPort: 9119", "fsGroupChangePolicy: OnRootMismatch", @@ -179,6 +181,57 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { } } +func TestGenerateConfig_BitwardenDisabledByDefault(t *testing.T) { + raw, err := generateConfig(testConfig(t), "gpt-5.2", DefaultBitwardenConfig()) + if err != nil { + t.Fatalf("generateConfig() error = %v", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v", err) + } + if _, ok := cfg["secrets"]; ok { + t.Fatalf("secrets config present when Bitwarden disabled: %#v", cfg["secrets"]) + } +} + +func TestGenerateConfig_RendersBitwardenSecrets(t *testing.T) { + bw := DefaultBitwardenConfig() + bw.Enabled = true + bw.ProjectID = "project-123" + bw.ServerURL = "https://vault.bitwarden.eu" + raw, err := generateConfig(testConfig(t), "gpt-5.2", bw) + if err != nil { + t.Fatalf("generateConfig() error = %v", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v", err) + } + secretsCfg, ok := cfg["secrets"].(map[string]any) + if !ok { + t.Fatalf("secrets config missing: %#v", cfg) + } + bitwardenCfg, ok := secretsCfg["bitwarden"].(map[string]any) + if !ok { + t.Fatalf("bitwarden config missing: %#v", secretsCfg) + } + want := map[string]any{ + "enabled": true, + "access_token_env": "BWS_ACCESS_TOKEN", + "project_id": "project-123", + "server_url": "https://vault.bitwarden.eu", + "cache_ttl_seconds": 300, + "override_existing": true, + "auto_install": true, + } + for key, expected := range want { + if got := bitwardenCfg[key]; got != expected { + t.Errorf("bitwarden.%s = %#v, want %#v", key, got, expected) + } + } +} + func TestDashboardHostname_UsesDefaultAgentHostAndHermesUIHostForNamedInstances(t *testing.T) { tests := []struct { id string diff --git a/internal/model/model.go b/internal/model/model.go index 3239a73b..353f65ea 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -243,10 +243,15 @@ func PatchLiteLLMProvider(cfg *config.Config, u *ui.UI, provider, apiKey string, if envVar != "" && apiKey != "" { u.Infof("Setting %s API key", provider) - patchJSON := fmt.Sprintf(`{"stringData":{"%s":"%s"}}`, envVar, apiKey) + patchJSON, err := json.Marshal(map[string]any{ + "stringData": map[string]string{envVar: apiKey}, + }) + if err != nil { + return fmt.Errorf("failed to encode secret patch: %w", err) + } if err := kubectl.Run(kubectlBinary, kubeconfigPath, "patch", "secret", secretName, "-n", namespace, - "-p", patchJSON, "--type=merge"); err != nil { + "-p", string(patchJSON), "--type=merge"); err != nil { return fmt.Errorf("failed to patch secret: %w", err) } } diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 6e905eee..988d89f9 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -321,17 +321,33 @@ type Agent struct { } type AgentSpec struct { - Runtime string `json:"runtime,omitempty"` - Model string `json:"model,omitempty"` - Skills []string `json:"skills,omitempty"` - Objective string `json:"objective,omitempty"` - Wallet AgentWallet `json:"wallet,omitempty"` + Runtime string `json:"runtime,omitempty"` + Model string `json:"model,omitempty"` + Skills []string `json:"skills,omitempty"` + Objective string `json:"objective,omitempty"` + Wallet AgentWallet `json:"wallet,omitempty"` + Secrets AgentSecrets `json:"secrets,omitempty"` } type AgentWallet struct { Create bool `json:"create,omitempty"` } +type AgentSecrets struct { + Bitwarden AgentBitwardenSecrets `json:"bitwarden,omitempty"` +} + +type AgentBitwardenSecrets struct { + Enabled bool `json:"enabled,omitempty"` + ProjectID string `json:"projectID,omitempty"` + ServerURL string `json:"serverURL,omitempty"` + AccessTokenSecretName string `json:"accessTokenSecretName,omitempty"` + AccessTokenKey string `json:"accessTokenKey,omitempty"` + OverrideExisting *bool `json:"overrideExisting,omitempty"` + CacheTTLSeconds *int `json:"cacheTTLSeconds,omitempty"` + AutoInstall *bool `json:"autoInstall,omitempty"` +} + type AgentStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` Phase string `json:"phase,omitempty"` diff --git a/internal/serviceoffercontroller/agent.go b/internal/serviceoffercontroller/agent.go index 9235121a..f72ae05f 100644 --- a/internal/serviceoffercontroller/agent.go +++ b/internal/serviceoffercontroller/agent.go @@ -247,6 +247,18 @@ func validateAgentSpec(agent *monetizeapi.Agent) (reason, message string, ok boo return "InvalidSkillEntry", "spec.skills contains an empty entry", false } } + bw := agent.Spec.Secrets.Bitwarden + if bw.Enabled { + if strings.TrimSpace(bw.ProjectID) == "" { + return "InvalidBitwardenConfig", "spec.secrets.bitwarden.projectID is required when Bitwarden is enabled", false + } + if bw.AccessTokenSecretName != "" && bw.AccessTokenSecretName != hermesEnvSecret { + return "InvalidBitwardenConfig", fmt.Sprintf("spec.secrets.bitwarden.accessTokenSecretName must be %q", hermesEnvSecret), false + } + if bw.AccessTokenKey != "" && strings.TrimSpace(bw.AccessTokenKey) == "" { + return "InvalidBitwardenConfig", "spec.secrets.bitwarden.accessTokenKey cannot be blank", false + } + } // Objective is optional at the CRD level (defaults to a neutral SOUL.md // when empty), so we don't reject on its absence here. diff --git a/internal/serviceoffercontroller/agent_render.go b/internal/serviceoffercontroller/agent_render.go index e44874cc..2bd90e48 100644 --- a/internal/serviceoffercontroller/agent_render.go +++ b/internal/serviceoffercontroller/agent_render.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -69,7 +70,7 @@ func agentManifests(agent *monetizeapi.Agent, litellmKey, apiKey string) ([]*uns return nil, fmt.Errorf("agentManifests: agent has no resolved model") } - configYAML := renderHermesConfig(model, litellmKey) + configYAML := renderHermesConfig(model, litellmKey, agent.Spec.Secrets.Bitwarden) out := []*unstructured.Unstructured{ buildAgentNamespace(agent.Namespace), @@ -88,8 +89,9 @@ func agentManifests(agent *monetizeapi.Agent, litellmKey, apiKey string) ([]*uns // so the embedded indentation in the ConfigMap stays exactly as Hermes // expects, matching the master agent's known-good shape from // internal/hermes.generateConfig. -func renderHermesConfig(model, litellmKey string) string { - return fmt.Sprintf(`model: +func renderHermesConfig(model, litellmKey string, bw monetizeapi.AgentBitwardenSecrets) string { + var b strings.Builder + fmt.Fprintf(&b, `model: default: %q provider: custom base_url: http://litellm.llm.svc.cluster.local:4000/v1 @@ -104,6 +106,35 @@ skills: external_dirs: - /data/.hermes/obol-skills `, model, litellmKey) + if bw.Enabled { + accessTokenKey := bw.AccessTokenKey + if strings.TrimSpace(accessTokenKey) == "" { + accessTokenKey = "BWS_ACCESS_TOKEN" + } + cacheTTL := 300 + if bw.CacheTTLSeconds != nil && *bw.CacheTTLSeconds > 0 { + cacheTTL = *bw.CacheTTLSeconds + } + overrideExisting := true + if bw.OverrideExisting != nil { + overrideExisting = *bw.OverrideExisting + } + autoInstall := true + if bw.AutoInstall != nil { + autoInstall = *bw.AutoInstall + } + fmt.Fprintf(&b, `secrets: + bitwarden: + enabled: true + access_token_env: %q + project_id: %q + server_url: %q + cache_ttl_seconds: %d + override_existing: %t + auto_install: %t +`, accessTokenKey, bw.ProjectID, bw.ServerURL, cacheTTL, overrideExisting, autoInstall) + } + return b.String() } func buildAgentNamespace(ns string) *unstructured.Unstructured { diff --git a/internal/serviceoffercontroller/agent_render_test.go b/internal/serviceoffercontroller/agent_render_test.go index e04e1e32..705fda2b 100644 --- a/internal/serviceoffercontroller/agent_render_test.go +++ b/internal/serviceoffercontroller/agent_render_test.go @@ -137,6 +137,61 @@ func TestAgentManifests_DeploymentEnvCarriesContext(t *testing.T) { } } +func TestAgentManifests_RendersBitwardenConfig(t *testing.T) { + agent := &monetizeapi.Agent{} + agent.Name = "quant" + agent.Namespace = "agent-quant" + cacheTTL := 120 + overrideExisting := false + autoInstall := true + agent.Spec = monetizeapi.AgentSpec{ + Model: "qwen3.5:9b", + Secrets: monetizeapi.AgentSecrets{ + Bitwarden: monetizeapi.AgentBitwardenSecrets{ + Enabled: true, + ProjectID: "project-123", + ServerURL: "https://vault.bitwarden.com", + AccessTokenKey: "BWS_ACCESS_TOKEN", + CacheTTLSeconds: &cacheTTL, + OverrideExisting: &overrideExisting, + AutoInstall: &autoInstall, + }, + }, + } + + out, err := agentManifests(agent, "litellm", "api") + if err != nil { + t.Fatalf("agentManifests: %v", err) + } + var cm map[string]any + for _, m := range out { + if m.GetKind() == "ConfigMap" && m.GetName() == hermesConfigMap { + cm = m.UnstructuredContent() + break + } + } + if cm == nil { + t.Fatal("ConfigMap manifest missing") + } + data := cm["data"].(map[string]any) + configYAML := data["config.yaml"].(string) + for _, needle := range []string{ + "secrets:", + "bitwarden:", + "enabled: true", + `access_token_env: "BWS_ACCESS_TOKEN"`, + `project_id: "project-123"`, + `server_url: "https://vault.bitwarden.com"`, + "cache_ttl_seconds: 120", + "override_existing: false", + "auto_install: true", + } { + if !strings.Contains(configYAML, needle) { + t.Fatalf("config missing %q:\n%s", needle, configYAML) + } + } +} + func TestAgentManifests_DeploymentUsesFSGroup(t *testing.T) { agent := &monetizeapi.Agent{} agent.Name = "quant" @@ -242,7 +297,7 @@ func TestAgentManifests_ProfileSeedInitContainer(t *testing.T) { } func TestRenderHermesConfig_HasModelAndSkillsDir(t *testing.T) { - cfg := renderHermesConfig("qwen3.5:9b", "lit-key") + cfg := renderHermesConfig("qwen3.5:9b", "lit-key", monetizeapi.AgentBitwardenSecrets{}) for _, must := range []string{ `default: "qwen3.5:9b"`, `api_key: "lit-key"`, diff --git a/internal/serviceoffercontroller/agent_test.go b/internal/serviceoffercontroller/agent_test.go index d6717a07..7ed5ab31 100644 --- a/internal/serviceoffercontroller/agent_test.go +++ b/internal/serviceoffercontroller/agent_test.go @@ -65,6 +65,34 @@ func TestValidateAgentSpec_RejectsBlankSkillEntry(t *testing.T) { } } +func TestValidateAgentSpec_RejectsInvalidBitwardenConfig(t *testing.T) { + a := &monetizeapi.Agent{ + Spec: monetizeapi.AgentSpec{ + Runtime: "hermes", + Secrets: monetizeapi.AgentSecrets{ + Bitwarden: monetizeapi.AgentBitwardenSecrets{Enabled: true}, + }, + }, + } + reason, _, ok := validateAgentSpec(a) + if ok { + t.Fatal("expected validator to reject missing Bitwarden project") + } + if reason != "InvalidBitwardenConfig" { + t.Errorf("reason = %q, want InvalidBitwardenConfig", reason) + } + + a.Spec.Secrets.Bitwarden.ProjectID = "project-123" + a.Spec.Secrets.Bitwarden.AccessTokenSecretName = "other-secret" + reason, _, ok = validateAgentSpec(a) + if ok { + t.Fatal("expected validator to reject custom Bitwarden token Secret") + } + if reason != "InvalidBitwardenConfig" { + t.Errorf("reason = %q, want InvalidBitwardenConfig", reason) + } +} + func TestSetAgentCondition_AddAndUpdate(t *testing.T) { var status monetizeapi.AgentStatus diff --git a/plans/bitwarden-secrets-integration-20260524.md b/plans/bitwarden-secrets-integration-20260524.md new file mode 100644 index 00000000..733e1d6c --- /dev/null +++ b/plans/bitwarden-secrets-integration-20260524.md @@ -0,0 +1,367 @@ +# Bitwarden Secrets Manager integration proposal + +## Summary + +Integrate Hermes' native Bitwarden Secrets Manager support as the stack's +optional centralized secret source for Hermes runtime environment variables. +The first version should not replace Kubernetes Secrets, LiteLLM's existing +`litellm-secrets`, or remote-signer keystores. It should only make the +Hermes process able to resolve provider and application API keys from +Bitwarden at startup by wiring the bootstrap `BWS_ACCESS_TOKEN` and the +`secrets.bitwarden.*` config fields into stack-managed Hermes deployments. + +Hermes already implements the runtime path upstream: with +`secrets.bitwarden.enabled: true`, it loads `~/.hermes/.env`, runs +`bws secret list `, and exports returned secret names into +`os.environ` before the gateway, CLI, or cron starts. The stack should expose +that capability through Obol CLI and manifests. `obol model setup` may also +use the same Bitwarden project as a provider-key source so LiteLLM can be +configured without the operator pasting API keys into the terminal. + +## Goals + +- Give operators one place to rotate Hermes-facing API keys across multiple + Obol Stack machines and agent pods. +- Keep the bootstrap token in Kubernetes Secret data, never in generated + ConfigMaps, plan files, PR text, logs, or git-tracked deployment files. +- Make the default `obol-agent` and CRD-created child Hermes agents support + the same Bitwarden shape. +- Let `obol model setup` read and validate Bitwarden-backed provider secrets + when the operator chooses Bitwarden as the API-key source. +- Preserve current local-first behavior: if Bitwarden is not configured, + `obol stack up`, `obol agent init`, `obol model setup`, and child-agent + creation continue to work exactly as they do today. +- Keep failure non-fatal. Hermes' Bitwarden integration already warns and + continues with existing environment values when sync fails; Obol should not + add stricter startup gates. + +## Non-goals + +- Do not store remote-signer wallet material in Bitwarden in v1. Wallets stay + in encrypted V3 keystores and remote-signer Secrets/PVCs. +- Do not make LiteLLM read Bitwarden directly in v1. `obol model setup` may + fetch a provider key from Bitwarden and then continue writing the active key + into `llm/litellm-secrets`, because LiteLLM's process does not run Hermes' + Bitwarden sync path. +- Do not add a new in-cluster Bitwarden controller or external-secrets + dependency in v1. +- Do not make Bitwarden mandatory for local single-machine installs. +- Do not support OpenClaw Bitwarden wiring. The Bitwarden subcommands should + reject `--runtime openclaw`. + +## Proposed user flow + +1. Operator creates a Bitwarden Secrets Manager machine account, grants read + access to a project, and creates an access token. +2. Operator stores provider keys in that project using environment-variable + names, for example `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, + `OPENROUTER_API_KEY`, `GITHUB_TOKEN`, or app-specific keys. +3. Operator runs: + + ```bash + obol agent secrets bitwarden setup obol-agent + ``` + + Interactive mode prompts for the access token, server region, and project. + Non-interactive mode accepts: + + ```bash + obol agent secrets bitwarden setup obol-agent \ + --access-token "$BWS_ACCESS_TOKEN" \ + --server-url https://vault.bitwarden.com \ + --project-id + ``` + +4. Obol writes or updates an in-cluster Secret named `hermes-env` in the + Hermes namespace with only the bootstrap token and optional server URL: + + ```yaml + stringData: + BWS_ACCESS_TOKEN: + BWS_SERVER_URL: https://vault.bitwarden.com + ``` + +5. Obol rewrites the Hermes `config.yaml` with: + + ```yaml + secrets: + bitwarden: + enabled: true + access_token_env: BWS_ACCESS_TOKEN + project_id: + server_url: https://vault.bitwarden.com + cache_ttl_seconds: 300 + override_existing: true + auto_install: true + ``` + +6. Obol restarts the Hermes Deployment. On next start, Hermes resolves the + Bitwarden project secrets into process environment variables. +7. Operator can then run provider setup without pasting the provider key: + + ```bash + obol model setup --provider openai --api-key-source bitwarden + ``` + + Obol reads the selected Hermes instance's non-secret Bitwarden metadata, + reads `BWS_ACCESS_TOKEN` from that instance's `hermes-env` Secret, fetches + the expected provider secret, validates it by configuring/probing LiteLLM + as `model setup` already does, writes the active value to + `llm/litellm-secrets`, and redacts the key from all output. + +## Runtime design + +### Default stack-managed Hermes + +The host-rendered Hermes path lives in `internal/hermes/hermes.go`. Today it +creates `hermes-api-server`, `hermes-config`, the data PVC, and the Deployment, +but it does not mount an operator-provided env Secret. + +Add an optional `hermes-env` Secret reference to the main Hermes container: + +```yaml +envFrom: + - secretRef: + name: hermes-env + optional: true +``` + +This mirrors the child-agent render path and keeps undeclared installs +unchanged. The Secret should be managed by a new CLI helper rather than by +the generated values file, so the access token never lands in +`$OBOL_CONFIG_DIR/applications/hermes//values-hermes.yaml`. + +Extend `generateConfig` to accept an optional Bitwarden config struct and +write `secrets.bitwarden` only when enabled. For disabled/default installs, +the generated YAML should stay byte-for-byte equivalent except for normal +marshal ordering changes covered by tests. + +### CRD-created child Hermes agents + +The controller-rendered path in `internal/serviceoffercontroller/agent_render.go` +already includes optional `envFrom.secretRef.name: hermes-env`, and the +agent-factory skill already knows how to create a `hermes-env` Secret. + +Add `spec.secrets.bitwarden` to the Agent CRD, for example: + +```yaml +spec: + secrets: + bitwarden: + enabled: true + projectID: + serverURL: https://vault.bitwarden.com + accessTokenSecretName: hermes-env + accessTokenKey: BWS_ACCESS_TOKEN + overrideExisting: true + cacheTTLSeconds: 300 + autoInstall: true +``` + +The controller should render these fields into Hermes `config.yaml`, but it +should not read or copy the access token. The token stays in `hermes-env`, +created either by the host CLI or by the agent-factory path. + +Admission policy already allows agent-created Secrets named `hermes-env` +inside `agent-*` namespaces. Keep that constraint; do not broaden it for +arbitrary Secret names in v1. + +### Agent factory + +Extend `agent-factory/scripts/factory.py` with flags: + +```text +--bitwarden-project-id +--bitwarden-server-url +--bitwarden-access-token-env BWS_ACCESS_TOKEN +--bitwarden-cache-ttl 300 +--bitwarden-no-override-existing +``` + +The existing `--env KEY=VALUE` mechanism can already populate +`BWS_ACCESS_TOKEN` in `hermes-env`. The factory should refuse +`--bitwarden-project-id` unless the env Secret includes the selected +bootstrap-token key, so spawned children do not enter a misleading +"enabled but no token" state. + +## CLI design + +Add native Obol commands under the existing agent-management surface as thin +wrappers around manifest/config wiring: + +```text +obol agent secrets bitwarden setup [instance-name] [--runtime hermes] [--access-token ...] [--server-url ...] --project-id ... +obol agent secrets bitwarden status [instance-name] [--runtime hermes] +obol agent secrets bitwarden disable [instance-name] [--runtime hermes] +``` + +Use `obol agent` here because the command mutates Obol-managed Kubernetes +Secrets, host deployment metadata, and rendered runtime config. Keep +`obol hermes` reserved for native Hermes CLI passthrough against a running pod. + +Responsibilities: + +- Reject non-Hermes runtimes; no OpenClaw support in v1. +- Validate the project ID shape and server URL. +- Apply or update `hermes-env` with `BWS_ACCESS_TOKEN` and `BWS_SERVER_URL`. +- Persist non-secret Bitwarden config in the host deployment metadata for + default/named Hermes instances. +- Re-render `values-hermes.yaml` and sync/restart the selected Hermes + instance. +- Redact `BWS_ACCESS_TOKEN` from all command output. +- `status` reports only Obol-managed state: metadata enabled/disabled, + project/server fields, whether `hermes-env` exists, and whether it contains + the expected bootstrap-token key. It does not shell into Hermes or call + Bitwarden. + +Do not shell into the pod and run `hermes secrets bitwarden setup` as the +primary path. That upstream wizard writes into pod-local files, while Obol +needs reproducible host-side deployment state and Kubernetes Secret wiring. +Operators who want upstream runtime diagnostics can still call native Hermes +through the passthrough, for example `obol hermes --agent secrets +bitwarden status`, after the pod is running. + +### Model setup integration + +Extend `obol model setup` with an explicit API-key source: + +```text +obol model setup --provider openai --api-key-source bitwarden [--agent obol-agent] +obol model setup --provider anthropic --api-key-source bitwarden [--agent obol-agent] +``` + +When `--api-key-source bitwarden` is selected, `model setup` maps provider to +the expected secret name: + +| Provider | Bitwarden secret name | +| --- | --- | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | + +Flow: + +1. Resolve the target Hermes instance, defaulting to `obol-agent`. +2. Load that instance's `bitwarden.yaml`. +3. Read `BWS_ACCESS_TOKEN` from the instance namespace's `hermes-env` Secret. +4. Fetch the provider secret from the configured Bitwarden project. +5. Validate the fetched key through the existing provider setup/probe path. +6. Patch `llm/litellm-secrets` and sync Hermes models exactly as the current + `obol model setup --api-key ...` path does. + +The fetched provider key is transient CLI process data. It should never be +written to host deployment metadata or printed. + +## Config persistence + +For host-managed Hermes instances, create a gitignored metadata file beside +the deployment: + +```text +$OBOL_CONFIG_DIR/applications/hermes//bitwarden.yaml +``` + +Contents: + +```yaml +enabled: true +project_id: +server_url: https://vault.bitwarden.com +access_token_env: BWS_ACCESS_TOKEN +cache_ttl_seconds: 300 +override_existing: true +auto_install: true +``` + +This file contains no access token, so it is safe in the user's config dir +but still should not be committed. `writeDeploymentFiles` reads it when +rendering Hermes config. `disable` flips `enabled: false` and restarts +Hermes, leaving the Kubernetes Secret in place so the operator can re-enable +without re-entering the token. + +## Secret mapping + +Use Bitwarden secret names exactly as Hermes expects: each secret name becomes +an environment variable name. Recommended project entries: + +| Secret name | Consumer | +| --- | --- | +| `OPENAI_API_KEY` | Hermes tools, direct provider use, optional model setup | +| `ANTHROPIC_API_KEY` | Hermes tools or provider-specific flows | +| `OPENROUTER_API_KEY` | Hermes model/provider plugins | +| `GITHUB_TOKEN` | Agent tools that need GitHub API access | +| App-specific names | Child-agent workloads | + +Do not store `BWS_ACCESS_TOKEN` in the same Bitwarden project. Hermes skips +overwriting its own bootstrap token, but keeping the bootstrap token outside +the fetched project reduces accidental self-reference and blast radius. + +## Security model + +- `BWS_ACCESS_TOKEN` is equivalent to read access for every secret in the + Bitwarden project granted to the machine account. Treat it as a high-value + bearer token. +- Store the token only in `hermes-env` Kubernetes Secret data, with no log + echoing and no generated YAML-on-disk copy. +- Keep one project per trust boundary: default operator agent, child agents, + and paid public agents should not all share one project unless they should + all read the same secrets. +- Prefer machine accounts scoped to read-only access on a single project. +- Rotating an API key happens in Bitwarden; rotating the bootstrap token + requires updating `hermes-env` and restarting the Hermes pod. +- If Bitwarden is unreachable, Hermes continues with existing environment + values. Operators should monitor warning logs rather than relying on pod + crash loops. + +## Implementation plan + +1. Add `hermes-env` optional `envFrom` to the default Hermes Deployment + renderer and tests. +2. Add a small `BitwardenSecretsConfig` type in `internal/hermes`, plus + load/save helpers for `/bitwarden.yaml`. +3. Extend `generateConfig` and `renderHermesConfig` to render + `secrets.bitwarden` when configured. +4. Add CLI subcommands under `obol agent secrets bitwarden`. +5. Extend `obol model setup` with `--api-key-source bitwarden` and provider + secret-name mapping. +6. Add Agent CRD schema for `spec.secrets.bitwarden` and controller render + support. +7. Extend `agent-factory` flags to set the Agent spec and validate token + presence in `hermes-env`. +8. Add docs covering setup, rotation, disable, model setup, and the boundary with + LiteLLM/remote-signer secrets. + +## Tests + +- `internal/hermes` unit test: default values include optional + `envFrom.secretRef.name: hermes-env`. +- `internal/hermes` unit test: `generateConfig` renders no + `secrets.bitwarden` block unless enabled. +- `internal/hermes` unit test: enabled Bitwarden config renders the Hermes + upstream field names exactly. +- CLI unit/helper tests: token redaction, metadata persistence, disable path. +- CLI unit/helper tests: `status` reports only metadata and Secret/key + presence without calling Bitwarden or Hermes. +- `model setup` tests: Bitwarden source maps provider to the expected secret + name, rejects missing Bitwarden config/token, validates fetched secret + plumbing, redacts fetched values, and then follows the existing LiteLLM + Secret patch path. +- Runtime validation tests: `obol agent secrets bitwarden * --runtime openclaw` + returns a clear unsupported-runtime error. +- `internal/serviceoffercontroller` tests: Agent CRD Bitwarden fields render + into the child Hermes ConfigMap and do not require controller Secret reads. +- Admission/CRD tests: existing `hermes-env` Secret constraint remains narrow. +- Smoke test: create a Bitwarden-enabled Hermes instance with a fake/local + Secret value and verify the pod receives `BWS_ACCESS_TOKEN` plus a + `secrets.bitwarden.enabled: true` config. Do not hit real Bitwarden in CI. + +## Rollout + +Ship behind explicit opt-in only: + +1. Add renderer support and CLI setup/status/disable. +2. Add `obol model setup --api-key-source bitwarden` for provider keys. +3. Document default-agent setup. +4. Add child-agent CRD/factory support. +5. Later, consider LiteLLM support through External Secrets Operator or a + first-class secret-sync sidecar if operators want cluster-wide provider + keys outside Hermes. From 5645cd9333dbb52ee2bb83a577df9e52a64a4bba Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:54:36 +0400 Subject: [PATCH 2/2] refactor: thin Bitwarden integration surface --- README.md | 13 +- cmd/obol/agent.go | 2 +- cmd/obol/agent_test.go | 2 +- .../skills/agent-factory/scripts/factory.py | 2 +- internal/hermes/bitwarden.go | 122 +----- internal/hermes/bitwarden_test.go | 33 +- .../bitwarden-secrets-integration-20260524.md | 367 ------------------ 7 files changed, 31 insertions(+), 510 deletions(-) delete mode 100644 plans/bitwarden-secrets-integration-20260524.md diff --git a/README.md b/README.md index 2cd329a0..bd9864dd 100644 --- a/README.md +++ b/README.md @@ -183,10 +183,10 @@ Use `obol agent` for Obol-managed lifecycle and auth flows. Use `obol hermes` fo ### Bitwarden Secrets Hermes agents can sync runtime environment variables from Bitwarden Secrets -Manager. Obol stores only the Bitwarden bootstrap token in the agent namespace's -`hermes-env` Secret; non-secret metadata is kept in the agent deployment config. -The setup command validates project access with the Bitwarden `bws` CLI on the -host. +Manager. Obol stores the Bitwarden bootstrap token, plus an optional server +override, in the agent namespace's `hermes-env` Secret; non-secret metadata is +kept in the agent deployment config. Hermes owns the Bitwarden fetch path at +runtime. ```bash obol agent secrets bitwarden setup obol-agent \ @@ -197,7 +197,7 @@ obol agent secrets bitwarden status obol-agent ``` Provider setup can then fetch the provider key from the configured Bitwarden -project, validate it, and write the active key to LiteLLM: +project with the Bitwarden `bws` CLI and write the active key to LiteLLM: ```bash obol model setup --provider openai --api-key-source bitwarden @@ -205,7 +205,8 @@ obol model setup --provider openai --api-key-source bitwarden Bitwarden secret names should match environment variable names such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. This integration is Hermes-only; -OpenClaw runtimes are not supported. +OpenClaw runtimes are not supported. Leave `--server-url` unset unless you use +EU Cloud or a self-hosted Bitwarden endpoint. ### Skills diff --git a/cmd/obol/agent.go b/cmd/obol/agent.go index fb5315ce..3f41a624 100644 --- a/cmd/obol/agent.go +++ b/cmd/obol/agent.go @@ -617,7 +617,7 @@ func agentBitwardenSecretsCommand(cfg *config.Config) *cli.Command { Flags: []cli.Flag{ agentRuntimeFlag("hermes"), &cli.StringFlag{Name: "project-id", Usage: "Bitwarden Secrets Manager project ID", Required: true}, - &cli.StringFlag{Name: "server-url", Usage: "Bitwarden server URL", Value: "https://vault.bitwarden.com"}, + &cli.StringFlag{Name: "server-url", Usage: "Optional Bitwarden server URL; empty uses the bws default"}, &cli.StringFlag{Name: "access-token", Usage: "Bitwarden machine-account access token", Sources: cli.EnvVars("BWS_ACCESS_TOKEN")}, &cli.StringFlag{Name: "access-token-env", Usage: "Environment variable name Hermes reads for the bootstrap token", Value: "BWS_ACCESS_TOKEN"}, &cli.IntFlag{Name: "cache-ttl", Usage: "Hermes Bitwarden cache TTL in seconds", Value: 300}, diff --git a/cmd/obol/agent_test.go b/cmd/obol/agent_test.go index 8fbeb63f..87311d8a 100644 --- a/cmd/obol/agent_test.go +++ b/cmd/obol/agent_test.go @@ -52,7 +52,7 @@ func TestAgentSecretsCommand_ExposesBitwarden(t *testing.T) { flags := flagMap(setup) requireFlags(t, flags, "runtime", "project-id", "server-url", "access-token", "access-token-env", "cache-ttl") assertStringDefault(t, flags, "runtime", "hermes") - assertStringDefault(t, flags, "server-url", "https://vault.bitwarden.com") + assertStringDefault(t, flags, "server-url", "") assertStringDefault(t, flags, "access-token-env", "BWS_ACCESS_TOKEN") } diff --git a/internal/embed/skills/agent-factory/scripts/factory.py b/internal/embed/skills/agent-factory/scripts/factory.py index f5166974..281b56ae 100644 --- a/internal/embed/skills/agent-factory/scripts/factory.py +++ b/internal/embed/skills/agent-factory/scripts/factory.py @@ -566,7 +566,7 @@ def build_parser(): create.add_argument("--create-wallet", action="store_true") create.add_argument("--env", action="append", default=[], help="Child env Secret entry KEY=VALUE") create.add_argument("--bitwarden-project-id", help="Enable Hermes Bitwarden secret sync for this child Agent") - create.add_argument("--bitwarden-server-url", default="https://vault.bitwarden.com") + create.add_argument("--bitwarden-server-url") create.add_argument("--bitwarden-access-token-env", default="BWS_ACCESS_TOKEN") create.add_argument("--bitwarden-cache-ttl", type=int, default=300) create.add_argument("--bitwarden-no-override-existing", action="store_true") diff --git a/internal/hermes/bitwarden.go b/internal/hermes/bitwarden.go index f67b81a9..889d868d 100644 --- a/internal/hermes/bitwarden.go +++ b/internal/hermes/bitwarden.go @@ -6,12 +6,10 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "os" "os/exec" "path/filepath" "strings" - "time" "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" @@ -21,12 +19,11 @@ import ( ) const ( - bitwardenConfigFileName = "bitwarden.yaml" - bitwardenEnvSecretName = "hermes-env" - defaultBitwardenTokenEnv = "BWS_ACCESS_TOKEN" - defaultBitwardenCacheTTL = 300 - defaultBitwardenServerEnv = "BWS_SERVER_URL" - defaultBitwardenCommandName = "bws" + bitwardenConfigFileName = "bitwarden.yaml" + bitwardenEnvSecretName = "hermes-env" + defaultBitwardenTokenEnv = "BWS_ACCESS_TOKEN" + defaultBitwardenCacheTTL = 300 + defaultBitwardenServerEnv = "BWS_SERVER_URL" ) // BitwardenConfig is Obol's non-secret metadata for Hermes' native @@ -55,20 +52,12 @@ type BitwardenStatus struct { ProjectID string `json:"project_id,omitempty"` ServerURL string `json:"server_url,omitempty"` AccessTokenEnv string `json:"access_token_env"` - CacheTTLSeconds int `json:"cache_ttl_seconds"` - OverrideExisting bool `json:"override_existing"` - AutoInstall bool `json:"auto_install"` MetadataPath string `json:"metadata_path"` EnvSecretExists bool `json:"env_secret_exists"` TokenKeyPresent bool `json:"token_key_present"` ServerURLPresent bool `json:"server_url_present"` } -type bitwardenSecret struct { - Key string `json:"key"` - Value string `json:"value"` -} - func DefaultBitwardenConfig() BitwardenConfig { return BitwardenConfig{ AccessTokenEnv: defaultBitwardenTokenEnv, @@ -86,14 +75,6 @@ func (c BitwardenConfig) normalized() BitwardenConfig { if c.CacheTTLSeconds <= 0 { c.CacheTTLSeconds = def.CacheTTLSeconds } - // Metadata written by this implementation always includes these booleans. - // If an older/incomplete file omits them, prefer the Hermes defaults. - if !c.OverrideExisting { - c.OverrideExisting = def.OverrideExisting - } - if !c.AutoInstall { - c.AutoInstall = def.AutoInstall - } return c } @@ -155,18 +136,6 @@ func SetupBitwarden(cfg *config.Config, id string, opts BitwardenSetupOptions, u if bw.ProjectID == "" { return errors.New("Bitwarden project ID is required") } - if err := validateBitwardenConfig(bw); err != nil { - return err - } - - u.Info("Validating Bitwarden project access") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - secrets, err := FetchBitwardenSecrets(ctx, bw, token) - if err != nil { - return err - } - u.Successf("Validated Bitwarden project (%d secret(s) readable)", len(secrets)) if err := saveBitwardenConfig(deploymentDir, bw); err != nil { return err @@ -216,9 +185,6 @@ func GetBitwardenStatus(cfg *config.Config, id string) (BitwardenStatus, error) ProjectID: bw.ProjectID, ServerURL: bw.ServerURL, AccessTokenEnv: bw.AccessTokenEnv, - CacheTTLSeconds: bw.CacheTTLSeconds, - OverrideExisting: bw.OverrideExisting, - AutoInstall: bw.AutoInstall, MetadataPath: bitwardenConfigPath(deploymentDir), EnvSecretExists: exists, TokenKeyPresent: hasToken, @@ -238,14 +204,11 @@ func FetchBitwardenSecretForAgent(ctx context.Context, cfg *config.Config, id, k if strings.TrimSpace(bw.ProjectID) == "" { return "", fmt.Errorf("Bitwarden project ID is not configured for hermes/%s", id) } - if err := validateBitwardenConfig(bw); err != nil { - return "", err - } token, err := readBitwardenBootstrapToken(cfg, id, bw.AccessTokenEnv) if err != nil { return "", err } - secrets, err := FetchBitwardenSecrets(ctx, bw, token) + secrets, err := fetchBitwardenSecrets(ctx, bw, token) if err != nil { return "", err } @@ -256,7 +219,7 @@ func FetchBitwardenSecretForAgent(ctx context.Context, cfg *config.Config, id, k return value, nil } -func FetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string) (map[string]string, error) { +func fetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string) (map[string]string, error) { bw = bw.normalized() token = strings.TrimSpace(token) if token == "" { @@ -265,17 +228,15 @@ func FetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string if strings.TrimSpace(bw.ProjectID) == "" { return nil, errors.New("Bitwarden project ID is required") } - if err := validateBitwardenConfig(bw); err != nil { - return nil, err - } bin := strings.TrimSpace(os.Getenv("OBOL_BWS_BIN")) if bin == "" { - bin = defaultBitwardenCommandName + bin = "bws" } cmd := exec.CommandContext(ctx, bin, "secret", "list", bw.ProjectID, "--output", "json") cmd.Env = append(os.Environ(), + "NO_COLOR=1", defaultBitwardenTokenEnv+"="+token, bw.AccessTokenEnv+"="+token, ) @@ -283,18 +244,17 @@ func FetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string cmd.Env = append(cmd.Env, defaultBitwardenServerEnv+"="+strings.TrimSpace(bw.ServerURL)) } - out, err := cmd.CombinedOutput() + out, err := cmd.Output() if err != nil { - msg := strings.TrimSpace(redactBitwardenToken(string(out), token)) - if msg != "" { - return nil, fmt.Errorf("bws secret list failed: %w: %s", err, msg) - } return nil, fmt.Errorf("bws secret list failed: %w", err) } - items, err := parseBitwardenSecretList(out) - if err != nil { - return nil, err + var items []struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.Unmarshal(out, &items); err != nil { + return nil, fmt.Errorf("parse bws secret list output: %w", err) } secrets := make(map[string]string, len(items)) for _, item := range items { @@ -306,48 +266,6 @@ func FetchBitwardenSecrets(ctx context.Context, bw BitwardenConfig, token string return secrets, nil } -func validateBitwardenConfig(bw BitwardenConfig) error { - if strings.TrimSpace(bw.AccessTokenEnv) == "" { - return errors.New("Bitwarden access token env var is required") - } - if strings.ContainsAny(bw.AccessTokenEnv, " \t\r\n=") { - return fmt.Errorf("invalid Bitwarden access token env var %q", bw.AccessTokenEnv) - } - if strings.TrimSpace(bw.ServerURL) != "" { - u, err := url.Parse(strings.TrimSpace(bw.ServerURL)) - if err != nil || u.Scheme == "" || u.Host == "" { - return fmt.Errorf("invalid Bitwarden server URL %q", bw.ServerURL) - } - if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("invalid Bitwarden server URL scheme %q", u.Scheme) - } - } - return nil -} - -func parseBitwardenSecretList(raw []byte) ([]bitwardenSecret, error) { - var items []bitwardenSecret - if err := json.Unmarshal(raw, &items); err == nil { - return items, nil - } - var wrapped struct { - Data []bitwardenSecret `json:"data"` - Secrets []bitwardenSecret `json:"secrets"` - Items []bitwardenSecret `json:"items"` - } - if err := json.Unmarshal(raw, &wrapped); err != nil { - return nil, fmt.Errorf("parse bws secret list output: %w", err) - } - switch { - case wrapped.Data != nil: - return wrapped.Data, nil - case wrapped.Secrets != nil: - return wrapped.Secrets, nil - default: - return wrapped.Items, nil - } -} - func applyBitwardenEnvSecret(cfg *config.Config, id string, bw BitwardenConfig, token string) error { if err := kubectl.EnsureCluster(cfg); err != nil { return err @@ -458,11 +376,3 @@ func restartHermesDeployment(cfg *config.Config, id string, u *ui.UI) error { u.Success("Hermes restarted") return nil } - -func redactBitwardenToken(value, token string) string { - token = strings.TrimSpace(token) - if token == "" { - return value - } - return strings.ReplaceAll(value, token, "[REDACTED]") -} diff --git a/internal/hermes/bitwarden_test.go b/internal/hermes/bitwarden_test.go index 86d5fdd7..fed4b98e 100644 --- a/internal/hermes/bitwarden_test.go +++ b/internal/hermes/bitwarden_test.go @@ -4,7 +4,6 @@ import ( "context" "os" "path/filepath" - "strings" "testing" ) @@ -31,6 +30,9 @@ func TestBitwardenConfigRoundTrip(t *testing.T) { if got.AccessTokenEnv != "BWS_ACCESS_TOKEN" { t.Fatalf("AccessTokenEnv = %q", got.AccessTokenEnv) } + if DefaultBitwardenConfig().ServerURL != "" { + t.Fatal("default ServerURL should be empty to match Hermes/bws defaults") + } } func TestFetchBitwardenSecretsUsesBWSCLI(t *testing.T) { @@ -54,36 +56,11 @@ printf '[{"key":"OPENAI_API_KEY","value":"sk-test"}]' cfg := DefaultBitwardenConfig() cfg.Enabled = true cfg.ProjectID = "project-123" - secrets, err := FetchBitwardenSecrets(context.Background(), cfg, "token-123") + secrets, err := fetchBitwardenSecrets(context.Background(), cfg, "token-123") if err != nil { - t.Fatalf("FetchBitwardenSecrets: %v", err) + t.Fatalf("fetchBitwardenSecrets: %v", err) } if got := secrets["OPENAI_API_KEY"]; got != "sk-test" { t.Fatalf("OPENAI_API_KEY = %q", got) } } - -func TestFetchBitwardenSecretsRedactsTokenOnError(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "bws") - if err := os.WriteFile(script, []byte(`#!/bin/sh -echo "bad token token-123" >&2 -exit 1 -`), 0o755); err != nil { - t.Fatalf("write fake bws: %v", err) - } - t.Setenv("OBOL_BWS_BIN", script) - - cfg := DefaultBitwardenConfig() - cfg.ProjectID = "project-123" - _, err := FetchBitwardenSecrets(context.Background(), cfg, "token-123") - if err == nil { - t.Fatal("expected error") - } - if strings.Contains(err.Error(), "token-123") { - t.Fatalf("error leaked token: %v", err) - } - if !strings.Contains(err.Error(), "[REDACTED]") { - t.Fatalf("error missing redaction marker: %v", err) - } -} diff --git a/plans/bitwarden-secrets-integration-20260524.md b/plans/bitwarden-secrets-integration-20260524.md deleted file mode 100644 index 733e1d6c..00000000 --- a/plans/bitwarden-secrets-integration-20260524.md +++ /dev/null @@ -1,367 +0,0 @@ -# Bitwarden Secrets Manager integration proposal - -## Summary - -Integrate Hermes' native Bitwarden Secrets Manager support as the stack's -optional centralized secret source for Hermes runtime environment variables. -The first version should not replace Kubernetes Secrets, LiteLLM's existing -`litellm-secrets`, or remote-signer keystores. It should only make the -Hermes process able to resolve provider and application API keys from -Bitwarden at startup by wiring the bootstrap `BWS_ACCESS_TOKEN` and the -`secrets.bitwarden.*` config fields into stack-managed Hermes deployments. - -Hermes already implements the runtime path upstream: with -`secrets.bitwarden.enabled: true`, it loads `~/.hermes/.env`, runs -`bws secret list `, and exports returned secret names into -`os.environ` before the gateway, CLI, or cron starts. The stack should expose -that capability through Obol CLI and manifests. `obol model setup` may also -use the same Bitwarden project as a provider-key source so LiteLLM can be -configured without the operator pasting API keys into the terminal. - -## Goals - -- Give operators one place to rotate Hermes-facing API keys across multiple - Obol Stack machines and agent pods. -- Keep the bootstrap token in Kubernetes Secret data, never in generated - ConfigMaps, plan files, PR text, logs, or git-tracked deployment files. -- Make the default `obol-agent` and CRD-created child Hermes agents support - the same Bitwarden shape. -- Let `obol model setup` read and validate Bitwarden-backed provider secrets - when the operator chooses Bitwarden as the API-key source. -- Preserve current local-first behavior: if Bitwarden is not configured, - `obol stack up`, `obol agent init`, `obol model setup`, and child-agent - creation continue to work exactly as they do today. -- Keep failure non-fatal. Hermes' Bitwarden integration already warns and - continues with existing environment values when sync fails; Obol should not - add stricter startup gates. - -## Non-goals - -- Do not store remote-signer wallet material in Bitwarden in v1. Wallets stay - in encrypted V3 keystores and remote-signer Secrets/PVCs. -- Do not make LiteLLM read Bitwarden directly in v1. `obol model setup` may - fetch a provider key from Bitwarden and then continue writing the active key - into `llm/litellm-secrets`, because LiteLLM's process does not run Hermes' - Bitwarden sync path. -- Do not add a new in-cluster Bitwarden controller or external-secrets - dependency in v1. -- Do not make Bitwarden mandatory for local single-machine installs. -- Do not support OpenClaw Bitwarden wiring. The Bitwarden subcommands should - reject `--runtime openclaw`. - -## Proposed user flow - -1. Operator creates a Bitwarden Secrets Manager machine account, grants read - access to a project, and creates an access token. -2. Operator stores provider keys in that project using environment-variable - names, for example `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, - `OPENROUTER_API_KEY`, `GITHUB_TOKEN`, or app-specific keys. -3. Operator runs: - - ```bash - obol agent secrets bitwarden setup obol-agent - ``` - - Interactive mode prompts for the access token, server region, and project. - Non-interactive mode accepts: - - ```bash - obol agent secrets bitwarden setup obol-agent \ - --access-token "$BWS_ACCESS_TOKEN" \ - --server-url https://vault.bitwarden.com \ - --project-id - ``` - -4. Obol writes or updates an in-cluster Secret named `hermes-env` in the - Hermes namespace with only the bootstrap token and optional server URL: - - ```yaml - stringData: - BWS_ACCESS_TOKEN: - BWS_SERVER_URL: https://vault.bitwarden.com - ``` - -5. Obol rewrites the Hermes `config.yaml` with: - - ```yaml - secrets: - bitwarden: - enabled: true - access_token_env: BWS_ACCESS_TOKEN - project_id: - server_url: https://vault.bitwarden.com - cache_ttl_seconds: 300 - override_existing: true - auto_install: true - ``` - -6. Obol restarts the Hermes Deployment. On next start, Hermes resolves the - Bitwarden project secrets into process environment variables. -7. Operator can then run provider setup without pasting the provider key: - - ```bash - obol model setup --provider openai --api-key-source bitwarden - ``` - - Obol reads the selected Hermes instance's non-secret Bitwarden metadata, - reads `BWS_ACCESS_TOKEN` from that instance's `hermes-env` Secret, fetches - the expected provider secret, validates it by configuring/probing LiteLLM - as `model setup` already does, writes the active value to - `llm/litellm-secrets`, and redacts the key from all output. - -## Runtime design - -### Default stack-managed Hermes - -The host-rendered Hermes path lives in `internal/hermes/hermes.go`. Today it -creates `hermes-api-server`, `hermes-config`, the data PVC, and the Deployment, -but it does not mount an operator-provided env Secret. - -Add an optional `hermes-env` Secret reference to the main Hermes container: - -```yaml -envFrom: - - secretRef: - name: hermes-env - optional: true -``` - -This mirrors the child-agent render path and keeps undeclared installs -unchanged. The Secret should be managed by a new CLI helper rather than by -the generated values file, so the access token never lands in -`$OBOL_CONFIG_DIR/applications/hermes//values-hermes.yaml`. - -Extend `generateConfig` to accept an optional Bitwarden config struct and -write `secrets.bitwarden` only when enabled. For disabled/default installs, -the generated YAML should stay byte-for-byte equivalent except for normal -marshal ordering changes covered by tests. - -### CRD-created child Hermes agents - -The controller-rendered path in `internal/serviceoffercontroller/agent_render.go` -already includes optional `envFrom.secretRef.name: hermes-env`, and the -agent-factory skill already knows how to create a `hermes-env` Secret. - -Add `spec.secrets.bitwarden` to the Agent CRD, for example: - -```yaml -spec: - secrets: - bitwarden: - enabled: true - projectID: - serverURL: https://vault.bitwarden.com - accessTokenSecretName: hermes-env - accessTokenKey: BWS_ACCESS_TOKEN - overrideExisting: true - cacheTTLSeconds: 300 - autoInstall: true -``` - -The controller should render these fields into Hermes `config.yaml`, but it -should not read or copy the access token. The token stays in `hermes-env`, -created either by the host CLI or by the agent-factory path. - -Admission policy already allows agent-created Secrets named `hermes-env` -inside `agent-*` namespaces. Keep that constraint; do not broaden it for -arbitrary Secret names in v1. - -### Agent factory - -Extend `agent-factory/scripts/factory.py` with flags: - -```text ---bitwarden-project-id ---bitwarden-server-url ---bitwarden-access-token-env BWS_ACCESS_TOKEN ---bitwarden-cache-ttl 300 ---bitwarden-no-override-existing -``` - -The existing `--env KEY=VALUE` mechanism can already populate -`BWS_ACCESS_TOKEN` in `hermes-env`. The factory should refuse -`--bitwarden-project-id` unless the env Secret includes the selected -bootstrap-token key, so spawned children do not enter a misleading -"enabled but no token" state. - -## CLI design - -Add native Obol commands under the existing agent-management surface as thin -wrappers around manifest/config wiring: - -```text -obol agent secrets bitwarden setup [instance-name] [--runtime hermes] [--access-token ...] [--server-url ...] --project-id ... -obol agent secrets bitwarden status [instance-name] [--runtime hermes] -obol agent secrets bitwarden disable [instance-name] [--runtime hermes] -``` - -Use `obol agent` here because the command mutates Obol-managed Kubernetes -Secrets, host deployment metadata, and rendered runtime config. Keep -`obol hermes` reserved for native Hermes CLI passthrough against a running pod. - -Responsibilities: - -- Reject non-Hermes runtimes; no OpenClaw support in v1. -- Validate the project ID shape and server URL. -- Apply or update `hermes-env` with `BWS_ACCESS_TOKEN` and `BWS_SERVER_URL`. -- Persist non-secret Bitwarden config in the host deployment metadata for - default/named Hermes instances. -- Re-render `values-hermes.yaml` and sync/restart the selected Hermes - instance. -- Redact `BWS_ACCESS_TOKEN` from all command output. -- `status` reports only Obol-managed state: metadata enabled/disabled, - project/server fields, whether `hermes-env` exists, and whether it contains - the expected bootstrap-token key. It does not shell into Hermes or call - Bitwarden. - -Do not shell into the pod and run `hermes secrets bitwarden setup` as the -primary path. That upstream wizard writes into pod-local files, while Obol -needs reproducible host-side deployment state and Kubernetes Secret wiring. -Operators who want upstream runtime diagnostics can still call native Hermes -through the passthrough, for example `obol hermes --agent secrets -bitwarden status`, after the pod is running. - -### Model setup integration - -Extend `obol model setup` with an explicit API-key source: - -```text -obol model setup --provider openai --api-key-source bitwarden [--agent obol-agent] -obol model setup --provider anthropic --api-key-source bitwarden [--agent obol-agent] -``` - -When `--api-key-source bitwarden` is selected, `model setup` maps provider to -the expected secret name: - -| Provider | Bitwarden secret name | -| --- | --- | -| `openai` | `OPENAI_API_KEY` | -| `anthropic` | `ANTHROPIC_API_KEY` | - -Flow: - -1. Resolve the target Hermes instance, defaulting to `obol-agent`. -2. Load that instance's `bitwarden.yaml`. -3. Read `BWS_ACCESS_TOKEN` from the instance namespace's `hermes-env` Secret. -4. Fetch the provider secret from the configured Bitwarden project. -5. Validate the fetched key through the existing provider setup/probe path. -6. Patch `llm/litellm-secrets` and sync Hermes models exactly as the current - `obol model setup --api-key ...` path does. - -The fetched provider key is transient CLI process data. It should never be -written to host deployment metadata or printed. - -## Config persistence - -For host-managed Hermes instances, create a gitignored metadata file beside -the deployment: - -```text -$OBOL_CONFIG_DIR/applications/hermes//bitwarden.yaml -``` - -Contents: - -```yaml -enabled: true -project_id: -server_url: https://vault.bitwarden.com -access_token_env: BWS_ACCESS_TOKEN -cache_ttl_seconds: 300 -override_existing: true -auto_install: true -``` - -This file contains no access token, so it is safe in the user's config dir -but still should not be committed. `writeDeploymentFiles` reads it when -rendering Hermes config. `disable` flips `enabled: false` and restarts -Hermes, leaving the Kubernetes Secret in place so the operator can re-enable -without re-entering the token. - -## Secret mapping - -Use Bitwarden secret names exactly as Hermes expects: each secret name becomes -an environment variable name. Recommended project entries: - -| Secret name | Consumer | -| --- | --- | -| `OPENAI_API_KEY` | Hermes tools, direct provider use, optional model setup | -| `ANTHROPIC_API_KEY` | Hermes tools or provider-specific flows | -| `OPENROUTER_API_KEY` | Hermes model/provider plugins | -| `GITHUB_TOKEN` | Agent tools that need GitHub API access | -| App-specific names | Child-agent workloads | - -Do not store `BWS_ACCESS_TOKEN` in the same Bitwarden project. Hermes skips -overwriting its own bootstrap token, but keeping the bootstrap token outside -the fetched project reduces accidental self-reference and blast radius. - -## Security model - -- `BWS_ACCESS_TOKEN` is equivalent to read access for every secret in the - Bitwarden project granted to the machine account. Treat it as a high-value - bearer token. -- Store the token only in `hermes-env` Kubernetes Secret data, with no log - echoing and no generated YAML-on-disk copy. -- Keep one project per trust boundary: default operator agent, child agents, - and paid public agents should not all share one project unless they should - all read the same secrets. -- Prefer machine accounts scoped to read-only access on a single project. -- Rotating an API key happens in Bitwarden; rotating the bootstrap token - requires updating `hermes-env` and restarting the Hermes pod. -- If Bitwarden is unreachable, Hermes continues with existing environment - values. Operators should monitor warning logs rather than relying on pod - crash loops. - -## Implementation plan - -1. Add `hermes-env` optional `envFrom` to the default Hermes Deployment - renderer and tests. -2. Add a small `BitwardenSecretsConfig` type in `internal/hermes`, plus - load/save helpers for `/bitwarden.yaml`. -3. Extend `generateConfig` and `renderHermesConfig` to render - `secrets.bitwarden` when configured. -4. Add CLI subcommands under `obol agent secrets bitwarden`. -5. Extend `obol model setup` with `--api-key-source bitwarden` and provider - secret-name mapping. -6. Add Agent CRD schema for `spec.secrets.bitwarden` and controller render - support. -7. Extend `agent-factory` flags to set the Agent spec and validate token - presence in `hermes-env`. -8. Add docs covering setup, rotation, disable, model setup, and the boundary with - LiteLLM/remote-signer secrets. - -## Tests - -- `internal/hermes` unit test: default values include optional - `envFrom.secretRef.name: hermes-env`. -- `internal/hermes` unit test: `generateConfig` renders no - `secrets.bitwarden` block unless enabled. -- `internal/hermes` unit test: enabled Bitwarden config renders the Hermes - upstream field names exactly. -- CLI unit/helper tests: token redaction, metadata persistence, disable path. -- CLI unit/helper tests: `status` reports only metadata and Secret/key - presence without calling Bitwarden or Hermes. -- `model setup` tests: Bitwarden source maps provider to the expected secret - name, rejects missing Bitwarden config/token, validates fetched secret - plumbing, redacts fetched values, and then follows the existing LiteLLM - Secret patch path. -- Runtime validation tests: `obol agent secrets bitwarden * --runtime openclaw` - returns a clear unsupported-runtime error. -- `internal/serviceoffercontroller` tests: Agent CRD Bitwarden fields render - into the child Hermes ConfigMap and do not require controller Secret reads. -- Admission/CRD tests: existing `hermes-env` Secret constraint remains narrow. -- Smoke test: create a Bitwarden-enabled Hermes instance with a fake/local - Secret value and verify the pod receives `BWS_ACCESS_TOKEN` plus a - `secrets.bitwarden.enabled: true` config. Do not hit real Bitwarden in CI. - -## Rollout - -Ship behind explicit opt-in only: - -1. Add renderer support and CLI setup/status/disable. -2. Add `obol model setup --api-key-source bitwarden` for provider keys. -3. Document default-agent setup. -4. Add child-agent CRD/factory support. -5. Later, consider LiteLLM support through External Secrets Operator or a - first-class secret-sync sidecar if operators want cluster-wide provider - keys outside Hermes.