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
151 changes: 151 additions & 0 deletions docs/guides/centaur-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Install Centaur on Obol Stack

[Centaur](https://github.com/paradigmxyz/centaur) is Paradigm's open-source
orchestrator of isolated coding agents — you @-mention a bot in Slack, the
bot spawns a per-conversation sandbox with shell + git + Python + Node, and
your harness of choice (codex / claude-code / amp / pi-mono) drives the work
back into the thread.

Running it on the Obol stack gets you two things you don't get from a
raw-Kubernetes install:

1. **Every Centaur agent runs through LiteLLM**, so any model in
`obol model list` — including `paid/<model>` aliases purchased via x402 —
is available inside sandboxes with zero extra config.
2. **One-command install with auto-generated secrets**: postgres password,
firewall CA, signing keys, and the LiteLLM master key are wired up by a
chart-managed bootstrap Job. You only paste Slack tokens.

> [!IMPORTANT]
> v1 supports a single LLM harness (`codex`, OpenAI-compatible through
> LiteLLM). Multi-harness selection, 1Password Connect, and gVisor sandboxing
> ship later.

> [!IMPORTANT]
> The bootstrap Job copies the LiteLLM master key into the sandbox env so
> agents can call `paid/*` models. This is acceptable for single-user
> installs where your obol wallet bounds the spend. Multi-tenant deployments
> should wait for v2 (per-install LiteLLM virtual keys).

## What you'll need

- Obol stack running locally (`obol stack up`).
- A Slack app (instructions below) with three secrets ready: bot token,
signing secret, and a service-to-service API key.
- Cloudflare tunnel hostname (auto-provisioned by `obol stack up`).
- ~3 GB of free disk for the bundled Postgres PVC.

## Step 1 — Create the Slack app

1. Visit [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**.
2. **OAuth & Permissions** → add bot scopes: `app_mentions:read`, `chat:write`,
`channels:history`, `groups:history`, `im:history`, `mpim:history`.
3. **Install to Workspace** → copy the **Bot User OAuth Token** (`xoxb-...`).
That's your `SLACK_BOT_TOKEN`.
4. **Basic Information** → copy the **Signing Secret**. That's your
`SLACK_SIGNING_SECRET`.
5. Mint a random string for `SLACKBOT_API_KEY` (used between slackbot ↔ api
internally — not Slack-issued):
```bash
openssl rand -hex 32
```
6. **Event Subscriptions** → paste this URL once you have your tunnel hostname
(you'll get it back from `obol app sync centaur`):
```
https://<your-tunnel-host>/api/webhooks/slack
```
7. Subscribe to bot events: `app_mention`, `message.channels`,
`message.groups`, `message.im`, `message.mpim`.

## Step 2 — Install

```bash
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_SIGNING_SECRET=...
export SLACKBOT_API_KEY=$(openssl rand -hex 32)

obol app install obol/centaur \
--set slack.botToken=$SLACK_BOT_TOKEN \
--set slack.signingSecret=$SLACK_SIGNING_SECRET \
--set slack.botApiKey=$SLACKBOT_API_KEY

obol app sync centaur
```

The bootstrap Job runs automatically before the main pods come up; it
generates the postgres password, firewall CA, iron-proxy management key,
sandbox signing key, and reads the LiteLLM master key from `llm/litellm-secrets`.
No further secret juggling.

When `obol app sync` finishes you'll see the slack webhook URL and the
internal REST endpoint:

```
tip: Configure your Slack app event subscription:
https://<tunnel-host>/api/webhooks/slack
tip: REST API: http://centaur.obol.stack
```

Paste the webhook URL into your Slack app (step 1.6 above).

## Step 3 — Use it

In any Slack channel where you've added the bot, mention it:

> @centaur write a quick prime-sieve in python and report wall-clock for 1e8

The bot opens a thread, the API spawns an isolated sandbox pod, the harness
runs against your preferred LiteLLM-routed model, and progress streams back
into the thread.

## What model runs inside the sandbox?

Whatever's at the head of `obol model list`. The sandbox calls
`litellm.llm.svc.cluster.local:4000` with OpenAI semantics; LiteLLM picks the
model. To change it:

```bash
obol model prefer paid/aeon # use a paid x402 model you've bought
obol model prefer qwen3.5:9b # use local Ollama (free, slower)
obol model sync # propagate
```

Centaur picks up the new default on the next sandbox spawn.

## Troubleshooting

**Slack `url_verification` fails on event subscription.** Tunnel may not be
running. Check `obol tunnel status`.

**Sandbox spawns but agent times out.** Likely LiteLLM-side. Port-forward and
inspect: `kubectl port-forward -n llm svc/litellm 14000:4000`, then
`curl http://127.0.0.1:14000/v1/models`.

**`centaur-bootstrap` Job in Error state.** Almost always RBAC: the Job needs
read access to `llm/litellm-secrets`. Confirm `obol stack up` finished
successfully before installing.

**Clock skew on Slack webhooks.** Slack rejects webhooks more than 5 minutes
out of date. If your k3d host has drifted (laptop suspend, VM clock issues),
slack integration silently fails. Resync your host clock.

## Tearing down

```bash
obol app delete centaur
```

Removes the namespace, PVC (deletes the postgres data), and all generated
secrets. Slack app definition stays in your Slack workspace and can be reused
for a future install.

## Reconfiguring

Edit `~/.config/obol/applications/centaur/<id>/values.yaml`, then:

```bash
obol app sync centaur
```

There's no `obol app configure` wizard yet — for v1 the edit-and-sync loop is
the supported reconfigure path.
43 changes: 29 additions & 14 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,41 @@ func Install(cfg *config.Config, u *ui.UI, chartRef string, opts InstallOptions)
return err
}

// 2. If repo/chart format, resolve via ArtifactHub
// 2. If repo/chart format, resolve the repo URL. Prefer the user's local
// `helm repo` config so charts published to repos already added during
// `obol stack up` (notably `obol` → obolnetwork.github.io/helm-charts)
// resolve without an ArtifactHub round-trip — and keep working when
// ArtifactHub is unreachable.
if chart.NeedsResolution() {
u.Info("Resolving chart via ArtifactHub...")
helmBinary := filepath.Join(cfg.BinDir, "helm")
if repos, err := helmcmd.LocalRepos(helmBinary); err == nil {
if url, ok := repos[chart.RepoName]; ok {
chart.RepoURL = url
u.Detail("Resolved (local helm repo)", fmt.Sprintf("%s/%s", chart.RepoName, chart.ChartName))
u.Detail("Repository URL", url)
}
}

client := NewArtifactHubClient()
if chart.RepoURL == "" {
u.Info("Resolving chart via ArtifactHub...")

info, err := client.ResolveChart(chartRef)
if err != nil {
return err
}
client := NewArtifactHubClient()

info, err := client.ResolveChart(chartRef)
if err != nil {
return err
}

chart.RepoURL = info.RepoURL
chart.RepoURL = info.RepoURL

chart.RepoName = info.RepoName
if chart.Version == "" {
chart.Version = info.Version
}
chart.RepoName = info.RepoName
if chart.Version == "" {
chart.Version = info.Version
}

u.Detail("Resolved", fmt.Sprintf("%s/%s version %s", info.RepoName, info.ChartName, info.Version))
u.Detail("Repository URL", info.RepoURL)
u.Detail("Resolved", fmt.Sprintf("%s/%s version %s", info.RepoName, info.ChartName, info.Version))
u.Detail("Repository URL", info.RepoURL)
}
}

// Apply version override from CLI flag
Expand Down
38 changes: 38 additions & 0 deletions internal/helmcmd/helmcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
package helmcmd

import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -185,3 +187,39 @@ func UpdateRepos(helmBinary string, names []string) ([]byte, error) {
out, err := cmd.CombinedOutput()
return out, err
}

// LocalRepos returns the user's helm-CLI repo configuration as a name → URL
// map by running `helm repo list -o json`. When no repos are configured helm
// exits non-zero with "no repositories" on stderr; that case is reported as
// an empty map with a nil error so callers can treat it as "nothing matched"
// rather than a hard failure.
func LocalRepos(helmBinary string) (map[string]string, error) {
cmd := exec.Command(helmBinary, "repo", "list", "-o", "json")
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && strings.Contains(string(ee.Stderr), "no repositories") {
return map[string]string{}, nil
}
return nil, fmt.Errorf("helm repo list: %w", err)
}
return parseHelmRepoList(out)
}

func parseHelmRepoList(data []byte) (map[string]string, error) {
var entries []struct {
Name string `json:"name"`
URL string `json:"url"`
}
if err := json.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("parse helm repo list json: %w", err)
}
repos := make(map[string]string, len(entries))
for _, e := range entries {
if e.Name == "" || e.URL == "" {
continue
}
repos[e.Name] = e.URL
}
return repos, nil
}
38 changes: 38 additions & 0 deletions internal/helmcmd/helmcmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,41 @@ func contains(haystack, needle string) bool {
}
return false
}

func TestParseHelmRepoList(t *testing.T) {
cases := []struct {
name string
in string
want map[string]string
}{
{
name: "two repos",
in: `[{"name":"obol","url":"https://obolnetwork.github.io/helm-charts/"},{"name":"ethpandaops","url":"https://ethpandaops.github.io/ethereum-helm-charts"}]`,
want: map[string]string{
"obol": "https://obolnetwork.github.io/helm-charts/",
"ethpandaops": "https://ethpandaops.github.io/ethereum-helm-charts",
},
},
{
name: "empty array",
in: `[]`,
want: map[string]string{},
},
{
name: "entry without url skipped",
in: `[{"name":"broken","url":""},{"name":"obol","url":"https://example.com"}]`,
want: map[string]string{"obol": "https://example.com"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseHelmRepoList([]byte(tc.in))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
Loading
Loading