Skip to content
Closed
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
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Integration tests use `//go:build integration` and skip gracefully when prerequi

**Design**: Deployment-centric (unique namespaces via petnames), local-first (k3d), XDG-compliant, two-stage templating (CLI flags → Go templates → Helmfile → K8s).

**Routing**: Traefik + Kubernetes Gateway API. GatewayClass `traefik`, Gateway `traefik-gateway` in `traefik` ns. Routes: `/` → frontend, `/rpc` → eRPC, `/services/<name>/*` → x402 ForwardAuth → upstream, `/.well-known/agent-registration.json` → ERC-8004 httpd, `/ethereum-<id>/execution|beacon`.
**Routing**: Traefik + Kubernetes Gateway API. GatewayClass `traefik`, Gateway `traefik-gateway` in `traefik` ns. Local-only routes (restricted to `hostnames: ["obol.stack"]`): `/` → frontend, `/rpc` → eRPC. Public routes (accessible via tunnel, no hostname restriction): `/services/<name>/*` → x402 ForwardAuth → upstream, `/.well-known/agent-registration.json` → ERC-8004 httpd, `/skill.md` → service catalog. Tunnel hostname gets a storefront landing page at `/`. NEVER remove hostname restrictions from frontend or eRPC HTTPRoutes — exposing the frontend/RPC to the public internet is a critical security flaw.

**Config**: `Config{ConfigDir, DataDir, BinDir}`. Precedence: `OBOL_CONFIG_DIR` > `XDG_CONFIG_HOME/obol` > `~/.config/obol`. `OBOL_DEVELOPMENT=true` → `.workspace/` dirs. All K8s tools auto-set `KUBECONFIG=$OBOL_CONFIG_DIR/kubeconfig.yaml`.

Expand Down Expand Up @@ -156,6 +156,22 @@ Skills = SKILL.md + optional scripts/references, embedded in `obol` binary (`int
4. **ExternalName services** — don't work with Traefik Gateway API, use ClusterIP + Endpoints
5. **eRPC `eth_call` cache** — default TTL is 10s for unfinalized reads, so `buy.py balance` can lag behind an already-settled paid request for a few seconds

### Security: Tunnel Exposure

The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gated endpoints and discovery metadata should be reachable via the tunnel hostname. Internal services (frontend, eRPC, LiteLLM, monitoring) MUST have `hostnames: ["obol.stack"]` on their HTTPRoutes to restrict them to local access.

**NEVER**:
- Remove `hostnames` restrictions from frontend or eRPC HTTPRoutes
- Create HTTPRoutes without `hostnames` for internal services
- Expose the frontend UI, Prometheus/monitoring, or LiteLLM admin to the tunnel
- Run `obol stack down` or `obol stack purge` unless explicitly asked

**Public routes** (no hostname restriction, intentional):
- `/services/*` — x402 payment-gated, safe by design
- `/.well-known/agent-registration.json` — ERC-8004 discovery
- `/skill.md` — machine-readable service catalog
- `/` on tunnel hostname — static storefront landing page (busybox httpd)

## Key Packages

| Package | Key Files | Role |
Expand Down
7 changes: 7 additions & 0 deletions cmd/obol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}
return tunnel.Restart(cfg, getUI(cmd))
},
},
{
Name: "stop",
Usage: "Stop the tunnel (scale cloudflared to 0 replicas)",
Action: func(ctx context.Context, cmd *cli.Command) error {
return tunnel.Stop(cfg, getUI(cmd))
},
},
{
Name: "logs",
Usage: "View cloudflared logs",
Expand Down
53 changes: 50 additions & 3 deletions cmd/obol/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,30 @@ func modelSetupCommand(cfg *config.Config) *cli.Command {

// Interactive mode if flags not provided
if provider == "" {
creds := detectCredentials()
providers, _ := model.GetAvailableProviders(cfg)
options := make([]string, len(providers))
for i, p := range providers {
options[i] = fmt.Sprintf("%s (%s)", p.Name, p.ID)
label := fmt.Sprintf("%s (%s)", p.Name, p.ID)
if det, ok := creds[p.ID]; ok {
label += fmt.Sprintf(" — detected: %s", det.source)
}
options[i] = label
}

idx, err := u.Select("Select a provider:", options, 0)
if err != nil {
return err
}
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 == "" {
u.Infof("%s API key detected (%s)", providers[idx].Name, det.source)
if u.Confirm("Use detected credential?", true) {
apiKey = det.key
}
}
}

// Provider-specific flow
Expand Down Expand Up @@ -145,9 +158,9 @@ func setupCloudProvider(cfg *config.Config, u *ui.UI, provider, apiKey string, m
// Sensible defaults
switch provider {
case "anthropic":
models = []string{"claude-sonnet-4-5-20250929"}
models = []string{"claude-sonnet-4-6"}
case "openai":
models = []string{"gpt-4o"}
models = []string{"gpt-4.1"}
}
}

Expand Down Expand Up @@ -370,6 +383,40 @@ func providerInfo(id string) model.ProviderInfo {
return model.ProviderInfo{ID: id, Name: id}
}

// detectedCredential describes a credential found in the environment.
type detectedCredential struct {
key string // the actual API key value (empty for Ollama)
source string // human-readable description of where it was found
}

// detectCredentials checks the environment for existing provider credentials.
// It returns a map of provider ID to detected credential info. Only providers
// with a detected credential appear in the map.
func detectCredentials() map[string]detectedCredential {
creds := make(map[string]detectedCredential)

// Anthropic: check ANTHROPIC_API_KEY, then CLAUDE_CODE_OAUTH_TOKEN
if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
creds["anthropic"] = detectedCredential{key: key, source: "ANTHROPIC_API_KEY"}
} else if key := os.Getenv("CLAUDE_CODE_OAUTH_TOKEN"); key != "" {
creds["anthropic"] = detectedCredential{key: key, source: "CLAUDE_CODE_OAUTH_TOKEN"}
}

// OpenAI: check OPENAI_API_KEY
if key := os.Getenv("OPENAI_API_KEY"); key != "" {
creds["openai"] = detectedCredential{key: key, source: "OPENAI_API_KEY"}
}

// Ollama: check if reachable with models
if ollamaModels, err := model.ListOllamaModels(); err == nil && len(ollamaModels) > 0 {
creds["ollama"] = detectedCredential{
source: fmt.Sprintf("%d model(s) available", len(ollamaModels)),
}
}

return creds
}

// promptModelPull interactively asks the user which Ollama model to pull.
func promptModelPull() (string, error) {
type suggestion struct {
Expand Down
Loading
Loading