Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,9 @@ temp/
bin

obol-agent

# Wallet backups (contain keystore passwords)
obol-wallet-backup-*.json
obol-wallet-backup-*.enc
.private_keys/
.claude/worktrees/
.claude/worktrees/
93 changes: 93 additions & 0 deletions cmd/obol/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/kubectl"
"github.com/ObolNetwork/obol-stack/internal/openclaw"
"github.com/urfave/cli/v3"
)
Expand Down Expand Up @@ -150,6 +151,7 @@ func openclawCommand(cfg *config.Config) *cli.Command {
},
},
openclawSkillsCommand(cfg),
openclawWalletCommand(cfg),
{
Name: "cli",
Usage: "Run openclaw CLI commands against a deployed instance",
Expand Down Expand Up @@ -186,6 +188,97 @@ func openclawCommand(cfg *config.Config) *cli.Command {
}
}

// openclawWalletCommand builds the "obol openclaw wallet" subcommand group.
func openclawWalletCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "wallet",
Usage: "Manage OpenClaw instance wallets",
Commands: []*cli.Command{
{
Name: "backup",
Usage: "Back up wallet keys for an OpenClaw instance",
Comment on lines +197 to +199
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BackupWalletCmd reads only local files (wallet metadata, keystore JSON, and values-remote-signer.yaml) and does not interact with the Kubernetes cluster at all. Requiring kubectl.EnsureCluster before running the backup command means users cannot back up their wallet keys if the cluster is down (which is precisely when they might need a backup most urgently, e.g. before running obol stack purge). The EnsureCluster guard should be removed from the backup action.

Copilot uses AI. Check for mistakes.
ArgsUsage: "[instance-name]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Usage: "Output file path",
},
&cli.StringFlag{
Name: "passphrase",
Usage: "Encryption passphrase (empty string = no encryption)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := kubectl.EnsureCluster(cfg); err != nil {
return err
}
id, _, err := openclaw.ResolveInstance(cfg, cmd.Args().Slice())
if err != nil {
return err
}
return openclaw.BackupWalletCmd(cfg, id, openclaw.BackupWalletOptions{
Output: cmd.String("output"),
Passphrase: cmd.String("passphrase"),
HasPassFlag: cmd.IsSet("passphrase"),
}, getUI(cmd))
},
},
{
Name: "restore",
Usage: "Restore wallet keys from a backup file",
ArgsUsage: "[instance-name]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Usage: "Backup file path",
Required: true,
},
&cli.StringFlag{
Name: "passphrase",
Usage: "Decryption passphrase",
},
&cli.BoolFlag{
Name: "force",
Usage: "Overwrite existing wallet",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := kubectl.EnsureCluster(cfg); err != nil {
return err
}
id, _, err := openclaw.ResolveInstance(cfg, cmd.Args().Slice())
if err != nil {
return err
}
return openclaw.RestoreWalletCmd(cfg, id, openclaw.RestoreWalletOptions{
Input: cmd.String("input"),
Passphrase: cmd.String("passphrase"),
HasPassFlag: cmd.IsSet("passphrase"),
Force: cmd.Bool("force"),
}, getUI(cmd))
},
},
{
Name: "list",
Usage: "List wallets for OpenClaw instances",
ArgsUsage: "[instance-name]",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args().Slice()
var id string
if len(args) > 0 {
var err error
id, _, err = openclaw.ResolveInstance(cfg, args)
if err != nil {
return err
}
}
return openclaw.ListWallets(cfg, id, getUI(cmd))
},
},
},
}
}

// openclawSkillsCommand builds the "obol openclaw skills" subcommand group.
func openclawSkillsCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Expand Down
8 changes: 4 additions & 4 deletions internal/openclaw/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func requireLiteLLMReady(t *testing.T, cfg *config.Config) {
// cluster is done separately via `obol openclaw sync`.
func scaffoldInstance(t *testing.T, cfg *config.Config, id string, ollamaModels []string) {
t.Helper()
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if err := os.MkdirAll(deploymentDir, 0755); err != nil {
t.Fatalf("failed to create deployment dir: %v", err)
}
Expand All @@ -205,7 +205,7 @@ func scaffoldCloudInstance(t *testing.T, cfg *config.Config, id string, cloud *C
hostname := fmt.Sprintf("openclaw-%s.%s", id, defaultDomain)
namespace := fmt.Sprintf("%s-%s", appName, id)

deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if err := os.MkdirAll(deploymentDir, 0755); err != nil {
t.Fatalf("failed to create deployment dir: %v", err)
}
Expand Down Expand Up @@ -687,7 +687,7 @@ func TestIntegration_SkillsStagedOnSync(t *testing.T) {
obolRun(t, cfg, "openclaw", "sync", id)

// 1. Verify skills were staged in the deployment directory
deployDir := deploymentPath(cfg, id)
deployDir := DeploymentPath(cfg, id)
skillsDir := filepath.Join(deployDir, "skills")
expectedSkills := []string{"distributed-validators", "ethereum-networks", "obol-stack", "addresses", "wallets"}

Expand Down Expand Up @@ -848,7 +848,7 @@ func TestIntegration_SkillsIdempotentSync(t *testing.T) {
obolRun(t, cfg, "openclaw", "sync", id)

// Add a custom file to the staged skills directory (simulating user customisation)
deployDir := deploymentPath(cfg, id)
deployDir := DeploymentPath(cfg, id)
marker := filepath.Join(deployDir, "skills", "custom-user-skill", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(marker), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
Expand Down
24 changes: 12 additions & 12 deletions internal/openclaw/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ type OnboardOptions struct {
func SetupDefault(cfg *config.Config, u *ui.UI) error {
// Check whether the default deployment already exists (re-sync path).
// If it does, proceed unconditionally — the overlay was already written.
deploymentDir := deploymentPath(cfg, "default")
deploymentDir := DeploymentPath(cfg, "default")
if _, err := os.Stat(deploymentDir); err == nil {
// Existing deployment — always re-sync regardless of Ollama status.
return Onboard(cfg, OnboardOptions{
Expand Down Expand Up @@ -134,7 +134,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error {
u.Infof("Using deployment ID: %s", id)
}

deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)

// Idempotent re-run for default deployment: just re-sync
if opts.IsDefault && !opts.Force {
Expand Down Expand Up @@ -264,7 +264,7 @@ agents:
os.RemoveAll(deploymentDir)
return fmt.Errorf("failed to write remote-signer values: %w", err)
}
if err := writeWalletMetadata(deploymentDir, wallet); err != nil {
if err := WriteWalletMetadata(deploymentDir, wallet); err != nil {
os.RemoveAll(deploymentDir)
return fmt.Errorf("failed to write wallet metadata: %w", err)
}
Expand Down Expand Up @@ -294,7 +294,7 @@ agents:
}
u.Blank()
u.Print(" Back up your signing key:")
u.Printf(" cp -r %s ~/obol-wallet-backup/", keystoreVolumePath(cfg, id))
u.Printf(" cp -r %s ~/obol-wallet-backup/", KeystoreVolumePath(cfg, id))

// Stage default skills to deployment directory (immediate, no cluster needed)
u.Blank()
Expand Down Expand Up @@ -334,7 +334,7 @@ func Sync(cfg *config.Config, id string, u *ui.UI) error {
}

func doSync(cfg *config.Config, id string, u *ui.UI) error {
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if _, err := os.Stat(deploymentDir); os.IsNotExist(err) {
return fmt.Errorf("deployment not found: %s/%s\nDirectory: %s", appName, id, deploymentDir)
}
Expand Down Expand Up @@ -912,7 +912,7 @@ type SetupOptions struct {
// It runs the interactive provider prompt, regenerates the overlay values,
// and syncs via helmfile so the pod picks up the new configuration.
func Setup(cfg *config.Config, id string, _ SetupOptions, u *ui.UI) error {
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if _, err := os.Stat(deploymentDir); os.IsNotExist(err) {
return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw onboard' first", appName, id)
}
Expand Down Expand Up @@ -983,7 +983,7 @@ type DashboardOptions struct {
// The onReady callback is invoked with the dashboard URL; the CLI layer uses it
// to open a browser.
func Dashboard(cfg *config.Config, id string, opts DashboardOptions, onReady func(url string), u *ui.UI) error {
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if _, err := os.Stat(deploymentDir); os.IsNotExist(err) {
return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw up' first", appName, id)
}
Expand Down Expand Up @@ -1076,7 +1076,7 @@ func List(cfg *config.Config, u *ui.UI) error {
// Delete removes an OpenClaw instance
func Delete(cfg *config.Config, id string, force bool, u *ui.UI) error {
namespace := fmt.Sprintf("%s-%s", appName, id)
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)

u.Infof("Deleting OpenClaw: %s/%s", appName, id)
u.Detail("Namespace", namespace)
Expand Down Expand Up @@ -1245,7 +1245,7 @@ var remoteCapableCommands = map[string]bool{
// others are executed via kubectl exec into the pod.
func CLI(cfg *config.Config, id string, args []string, u *ui.UI) error {
_ = u // interactive passthrough — subprocess owns stdout/stderr
deploymentDir := deploymentPath(cfg, id)
deploymentDir := DeploymentPath(cfg, id)
if _, err := os.Stat(deploymentDir); os.IsNotExist(err) {
return fmt.Errorf("deployment not found: %s/%s\nRun 'obol openclaw up' first", appName, id)
}
Expand Down Expand Up @@ -1371,7 +1371,7 @@ func SyncOverlayModels(cfg *config.Config, models []string, u *ui.UI) error {
masterKey := litellmMasterKey(cfg)

for _, id := range ids {
overlayPath := filepath.Join(deploymentPath(cfg, id), "values-obol.yaml")
overlayPath := filepath.Join(DeploymentPath(cfg, id), "values-obol.yaml")
data, err := os.ReadFile(overlayPath)
if err != nil {
continue
Expand Down Expand Up @@ -1653,8 +1653,8 @@ func patchOverlayModelList(content string, models []string) (string, bool) {
return strings.Join(result, "\n"), true
}

// deploymentPath returns the path to a deployment directory
func deploymentPath(cfg *config.Config, id string) string {
// DeploymentPath returns the path to a deployment directory.
func DeploymentPath(cfg *config.Config, id string) string {
return filepath.Join(cfg.ConfigDir, "applications", appName, id)
}

Expand Down
18 changes: 9 additions & 9 deletions internal/openclaw/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,10 @@ func generateRandomPassword(length int) (string, error) {
return string(result), nil
}

// keystoreVolumePath returns the host-side path where the remote-signer's
// KeystoreVolumePath returns the host-side path where the remote-signer's
// PVC stores keystores. This follows the local-path-provisioner pattern:
// $DATA_DIR/<namespace>/<pvc-name>/
func keystoreVolumePath(cfg *config.Config, id string) string {
func KeystoreVolumePath(cfg *config.Config, id string) string {
namespace := fmt.Sprintf("%s-%s", appName, id)
return filepath.Join(cfg.DataDir, namespace, "remote-signer-keystores")
}
Expand All @@ -324,7 +324,7 @@ func keystoreVolumePath(cfg *config.Config, id string) string {
// path before the remote-signer pod starts. Returns the absolute path to the
// written keystore file.
func provisionKeystoreToVolume(cfg *config.Config, id, keystoreID string, keystoreJSON []byte) (string, error) {
dir := keystoreVolumePath(cfg, id)
dir := KeystoreVolumePath(cfg, id)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create keystore directory: %w", err)
}
Expand Down Expand Up @@ -359,18 +359,18 @@ func walletMetadataPath(deploymentDir string) string {
return filepath.Join(deploymentDir, "wallet.json")
}

// writeWalletMetadata writes the wallet address and UUID to a JSON file
// WriteWalletMetadata writes the wallet address and UUID to a JSON file
// in the deployment directory for re-sync and display purposes.
func writeWalletMetadata(deploymentDir string, wallet *WalletInfo) error {
func WriteWalletMetadata(deploymentDir string, wallet *WalletInfo) error {
data, err := json.MarshalIndent(wallet, "", " ")
if err != nil {
return fmt.Errorf("marshal wallet metadata: %w", err)
}
return os.WriteFile(walletMetadataPath(deploymentDir), data, 0644)
}

// readWalletMetadata reads existing wallet metadata from the deployment directory.
func readWalletMetadata(deploymentDir string) (*WalletInfo, error) {
// ReadWalletMetadata reads existing wallet metadata from the deployment directory.
func ReadWalletMetadata(deploymentDir string) (*WalletInfo, error) {
data, err := os.ReadFile(walletMetadataPath(deploymentDir))
if err != nil {
return nil, err
Expand Down Expand Up @@ -411,7 +411,7 @@ func ensureWallet(cfg *config.Config, id, deploymentDir string) {
return
}

if err := writeWalletMetadata(deploymentDir, wallet); err != nil {
if err := WriteWalletMetadata(deploymentDir, wallet); err != nil {
fmt.Printf("Warning: could not write wallet metadata: %v\n", err)
return
}
Expand All @@ -423,7 +423,7 @@ func ensureWallet(cfg *config.Config, id, deploymentDir string) {
// in the instance namespace. The frontend reads this to display wallet addresses.
// Must be called after helmfile sync (namespace must exist).
func applyWalletMetadataConfigMap(cfg *config.Config, id, deploymentDir string) {
wallet, err := readWalletMetadata(deploymentDir)
wallet, err := ReadWalletMetadata(deploymentDir)
if err != nil {
return // no wallet metadata, nothing to apply
}
Expand Down
Loading
Loading