diff --git a/README.md b/README.md index 818f9f3f..bd9864dd 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,34 @@ 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 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 \ + --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 with the Bitwarden `bws` CLI 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. Leave `--server-url` unset unless you use +EU Cloud or a self-hosted Bitwarden endpoint. + ### 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..3f41a624 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: "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}, + }, + 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..87311d8a 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", "") + 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..281b56ae 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") + 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..889d868d --- /dev/null +++ b/internal/hermes/bitwarden.go @@ -0,0 +1,378 @@ +package hermes + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "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" +) + +// 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"` + MetadataPath string `json:"metadata_path"` + EnvSecretExists bool `json:"env_secret_exists"` + TokenKeyPresent bool `json:"token_key_present"` + ServerURLPresent bool `json:"server_url_present"` +} + +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 + } + 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 := 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, + 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) + } + 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") + } + + bin := strings.TrimSpace(os.Getenv("OBOL_BWS_BIN")) + if bin == "" { + 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, + ) + if strings.TrimSpace(bw.ServerURL) != "" { + cmd.Env = append(cmd.Env, defaultBitwardenServerEnv+"="+strings.TrimSpace(bw.ServerURL)) + } + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("bws secret list failed: %w", 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 { + if strings.TrimSpace(item.Key) == "" { + continue + } + secrets[item.Key] = item.Value + } + return secrets, 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 +} diff --git a/internal/hermes/bitwarden_test.go b/internal/hermes/bitwarden_test.go new file mode 100644 index 00000000..fed4b98e --- /dev/null +++ b/internal/hermes/bitwarden_test.go @@ -0,0 +1,66 @@ +package hermes + +import ( + "context" + "os" + "path/filepath" + "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) + } + if DefaultBitwardenConfig().ServerURL != "" { + t.Fatal("default ServerURL should be empty to match Hermes/bws defaults") + } +} + +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) + } +} 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