Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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 <project-uuid> \
--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.
Expand Down
126 changes: 126 additions & 0 deletions cmd/obol/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ Hermes/OpenClaw onboard flow used by the master agent.`,
},
},
agentUpdateCommand(cfg),
agentSecretsCommand(cfg),
agentWalletCommand(cfg),
},
}
Expand Down Expand Up @@ -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":
Expand Down
43 changes: 35 additions & 8 deletions cmd/obol/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
51 changes: 50 additions & 1 deletion cmd/obol/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)",
Expand All @@ -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 == "" {
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cmd/obol/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion internal/agentcrd/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
8 changes: 7 additions & 1 deletion internal/embed/embed_crd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading