From 40cf04e35f43327aebef33062d61be6a0fcaf887 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 09:58:05 +0400 Subject: [PATCH 1/8] Dynamic provider discovery from llmspy pod Remove hardcoded provider map (anthropic/openai) and instead query the running llmspy pod's providers.json for env var names and available providers. This means any provider llmspy supports (zai, deepseek, google, mistral, etc.) works with `obol model setup` without code changes. --- cmd/obol/model.go | 50 +++++++++------- internal/model/model.go | 127 +++++++++++++++++++++++++++++++++------- 2 files changed, 135 insertions(+), 42 deletions(-) diff --git a/cmd/obol/model.go b/cmd/obol/model.go index c16c062e..901d67a0 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -23,7 +23,7 @@ func modelCommand(cfg *config.Config) *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "provider", - Usage: "Provider name (anthropic, openai)", + Usage: "Provider name (e.g. anthropic, openai, zai, deepseek)", }, &cli.StringFlag{ Name: "api-key", @@ -38,7 +38,7 @@ func modelCommand(cfg *config.Config) *cli.Command { // Interactive mode if flags not provided if provider == "" || apiKey == "" { var err error - provider, apiKey, err = promptModelConfig() + provider, apiKey, err = promptModelConfig(cfg) if err != nil { return err } @@ -64,7 +64,7 @@ func modelCommand(cfg *config.Config) *cli.Command { fmt.Println("Global llmspy providers:") fmt.Println() - fmt.Printf(" %-12s %-8s %-10s %s\n", "PROVIDER", "ENABLED", "API KEY", "ENV VAR") + fmt.Printf(" %-20s %-8s %-10s %s\n", "PROVIDER", "ENABLED", "API KEY", "ENV VAR") for _, name := range providers { s := status[name] key := "n/a" @@ -75,8 +75,12 @@ func modelCommand(cfg *config.Config) *cli.Command { key = "missing" } } - fmt.Printf(" %-12s %-8t %-10s %s\n", name, s.Enabled, key, s.EnvVar) + fmt.Printf(" %-20s %-8t %-10s %s\n", name, s.Enabled, key, s.EnvVar) } + + // Show hint about available providers + fmt.Println() + fmt.Println("Run 'obol model setup' to configure a provider.") return nil }, }, @@ -85,13 +89,23 @@ func modelCommand(cfg *config.Config) *cli.Command { } // promptModelConfig interactively asks the user for provider and API key. -func promptModelConfig() (string, string, error) { +// It queries the running llmspy pod for available providers. +func promptModelConfig(cfg *config.Config) (string, string, error) { + providers, err := model.GetAvailableProviders(cfg) + if err != nil { + return "", "", fmt.Errorf("failed to discover providers: %w", err) + } + if len(providers) == 0 { + return "", "", fmt.Errorf("no cloud providers found in llmspy") + } + reader := bufio.NewReader(os.Stdin) - fmt.Println("Select a provider:") - fmt.Println(" [1] Anthropic") - fmt.Println(" [2] OpenAI") - fmt.Print("\nChoice [1]: ") + fmt.Println("Available providers:") + for i, p := range providers { + fmt.Printf(" [%d] %s (%s)\n", i+1, p.Name, p.ID) + } + fmt.Printf("\nChoice [1]: ") line, _ := reader.ReadString('\n') choice := strings.TrimSpace(line) @@ -99,24 +113,18 @@ func promptModelConfig() (string, string, error) { choice = "1" } - var provider, display string - switch choice { - case "1": - provider = "anthropic" - display = "Anthropic" - case "2": - provider = "openai" - display = "OpenAI" - default: - return "", "", fmt.Errorf("unknown choice: %s", choice) + idx := 0 + if _, err := fmt.Sscanf(choice, "%d", &idx); err != nil || idx < 1 || idx > len(providers) { + return "", "", fmt.Errorf("invalid choice: %s", choice) } + selected := providers[idx-1] - fmt.Printf("\n%s API key: ", display) + fmt.Printf("\n%s API key (%s): ", selected.Name, selected.EnvVar) apiKey, _ := reader.ReadString('\n') apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return "", "", fmt.Errorf("API key is required") } - return provider, apiKey, nil + return selected.ID, apiKey, nil } diff --git a/internal/model/model.go b/internal/model/model.go index 03990568..6c8a50b4 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -19,10 +19,11 @@ const ( deployName = "llmspy" ) -// providerEnvKeys maps provider names to their Secret key names. -var providerEnvKeys = map[string]string{ - "anthropic": "ANTHROPIC_API_KEY", - "openai": "OPENAI_API_KEY", +// ProviderInfo describes an llmspy provider discovered from the running pod. +type ProviderInfo struct { + ID string // provider id (e.g. "zai", "anthropic") + Name string // display name (e.g. "Z.AI", "Anthropic") + EnvVar string // env var for API key (e.g. "ZHIPU_API_KEY") } // ProviderStatus captures effective global llmspy provider state. @@ -33,14 +34,10 @@ type ProviderStatus struct { } // ConfigureLLMSpy enables a cloud provider in the llmspy gateway. -// It patches the llms-secrets Secret with the API key, enables the provider +// It discovers the provider's env var from the running llmspy pod, +// patches the llms-secrets Secret with the API key, enables the provider // in the llmspy-config ConfigMap, and restarts the deployment. func ConfigureLLMSpy(cfg *config.Config, provider, apiKey string) error { - envKey, ok := providerEnvKeys[provider] - if !ok { - return fmt.Errorf("unsupported llmspy provider: %s (supported: anthropic, openai)", provider) - } - kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -48,6 +45,12 @@ func ConfigureLLMSpy(cfg *config.Config, provider, apiKey string) error { return fmt.Errorf("cluster not running. Run 'obol stack up' first") } + // Discover the env var name from the llmspy pod's providers.json + envKey, err := getProviderEnvKey(kubectlBinary, kubeconfigPath, provider) + if err != nil { + return err + } + // 1. Patch the Secret with the API key fmt.Printf("Configuring llmspy: setting %s key...\n", provider) patchJSON := fmt.Sprintf(`{"stringData":{"%s":"%s"}}`, envKey, apiKey) @@ -83,7 +86,72 @@ func ConfigureLLMSpy(cfg *config.Config, provider, apiKey string) error { return nil } -// GetProviderStatus reads llmspy ConfigMap + Secret and returns global provider status. +// getProviderEnvKey queries the llmspy pod for the env var name a provider uses. +// It reads the merged providers.json inside the pod (package defaults + ConfigMap overrides). +func getProviderEnvKey(kubectlBinary, kubeconfigPath, provider string) (string, error) { + script := fmt.Sprintf(`import json +with open('/home/llms/.llms/providers.json') as f: + d = json.load(f) +p = d.get('%s') +if p and p.get('env'): + print(p['env'][0]) +`, provider) + + output, err := kubectlOutput(kubectlBinary, kubeconfigPath, + "exec", "-n", namespace, fmt.Sprintf("deploy/%s", deployName), "--", + "python3", "-c", script) + if err != nil { + return "", fmt.Errorf("failed to query llmspy for provider %q: %w", provider, err) + } + envKey := strings.TrimSpace(output) + if envKey == "" { + return "", fmt.Errorf("unknown provider %q — run 'obol model status' to see available providers", provider) + } + return envKey, nil +} + +// GetAvailableProviders queries the llmspy pod for all providers that accept an API key. +func GetAvailableProviders(cfg *config.Config) ([]ProviderInfo, error) { + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { + return nil, fmt.Errorf("cluster not running. Run 'obol stack up' first") + } + + script := `import json +with open('/home/llms/.llms/providers.json') as f: + d = json.load(f) +for pid in sorted(d): + p = d[pid] + env = p.get('env', []) + if env: + print(pid + '\t' + p.get('name', pid) + '\t' + env[0]) +` + output, err := kubectlOutput(kubectlBinary, kubeconfigPath, + "exec", "-n", namespace, fmt.Sprintf("deploy/%s", deployName), "--", + "python3", "-c", script) + if err != nil { + return nil, fmt.Errorf("failed to query llmspy providers: %w", err) + } + + var providers []ProviderInfo + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) != 3 { + continue + } + providers = append(providers, ProviderInfo{ + ID: parts[0], + Name: parts[1], + EnvVar: parts[2], + }) + } + return providers, nil +} + +// GetProviderStatus reads llmspy state and returns global provider status. +// It queries the llmspy pod for available providers and cross-references +// with the ConfigMap (enabled/disabled) and Secret (API keys). func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -91,6 +159,17 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { return nil, fmt.Errorf("cluster not running. Run 'obol stack up' first") } + // Get all available providers from llmspy (with env var names) + available, err := GetAvailableProviders(cfg) + if err != nil { + return nil, err + } + envKeyByProvider := make(map[string]string) + for _, p := range available { + envKeyByProvider[p.ID] = p.EnvVar + } + + // Read enabled/disabled state from ConfigMap llmsRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath, "get", "configmap", configMapName, "-n", namespace, "-o", "jsonpath={.data.llms\\.json}") if err != nil { @@ -102,6 +181,8 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { } status := make(map[string]ProviderStatus) + + // Seed from ConfigMap providers (shows what's been configured) if providers, ok := llmsConfig["providers"].(map[string]interface{}); ok { for name, raw := range providers { enabled := false @@ -110,17 +191,15 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { enabled = v } } - keyEnv := providerEnvKeys[name] status[name] = ProviderStatus{ - Enabled: enabled, - // Ollama needs no API key, so it's always considered "has key". - // Cloud providers are updated below from the actual K8s Secret. + Enabled: enabled, HasAPIKey: name == "ollama", - EnvVar: keyEnv, + EnvVar: envKeyByProvider[name], } } } + // Read Secret to check which API keys are set secretRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath, "get", "secret", secretName, "-n", namespace, "-o", "json") if err != nil { @@ -133,15 +212,21 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { return nil, fmt.Errorf("failed to parse llms secret: %w", err) } - for provider, envKey := range providerEnvKeys { - st := status[provider] - st.EnvVar = envKey - if v, ok := secret.Data[envKey]; ok && strings.TrimSpace(v) != "" { + // Cross-reference Secret keys with provider env vars + secretKeys := make(map[string]bool) + for k, v := range secret.Data { + if strings.TrimSpace(v) != "" { + secretKeys[k] = true + } + } + for name, st := range status { + if st.EnvVar != "" && secretKeys[st.EnvVar] { st.HasAPIKey = true + status[name] = st } - status[provider] = st } + // Ensure Ollama always shows if _, ok := status["ollama"]; !ok { status["ollama"] = ProviderStatus{ Enabled: true, From 687f05e77c0e7711d12ba74ecca4ba155c41ce6d Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 11:07:42 +0400 Subject: [PATCH 2/8] Add unit tests for dynamic provider discovery logic Extract pure functions (parseProviderEnvKey, parseAvailableProviders, buildProviderStatus, patchLLMsJSON) from kubectl-calling wrappers so they can be tested without a running cluster. 23 test cases covering parsing, status cross-referencing, JSON patching, and edge cases. --- internal/model/model.go | 119 ++++++----- internal/model/model_test.go | 369 +++++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 48 deletions(-) create mode 100644 internal/model/model_test.go diff --git a/internal/model/model.go b/internal/model/model.go index 6c8a50b4..6ce25f52 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -103,6 +103,11 @@ if p and p.get('env'): if err != nil { return "", fmt.Errorf("failed to query llmspy for provider %q: %w", provider, err) } + return parseProviderEnvKey(provider, output) +} + +// parseProviderEnvKey extracts an env var name from kubectl exec output. +func parseProviderEnvKey(provider, output string) (string, error) { envKey := strings.TrimSpace(output) if envKey == "" { return "", fmt.Errorf("unknown provider %q — run 'obol model status' to see available providers", provider) @@ -134,8 +139,17 @@ for pid in sorted(d): return nil, fmt.Errorf("failed to query llmspy providers: %w", err) } + return parseAvailableProviders(output), nil +} + +// parseAvailableProviders parses tab-separated kubectl exec output into ProviderInfo slices. +func parseAvailableProviders(output string) []ProviderInfo { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return nil + } var providers []ProviderInfo - for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + for _, line := range strings.Split(trimmed, "\n") { parts := strings.SplitN(line, "\t", 3) if len(parts) != 3 { continue @@ -146,7 +160,7 @@ for pid in sorted(d): EnvVar: parts[2], }) } - return providers, nil + return providers } // GetProviderStatus reads llmspy state and returns global provider status. @@ -164,10 +178,6 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { if err != nil { return nil, err } - envKeyByProvider := make(map[string]string) - for _, p := range available { - envKeyByProvider[p.ID] = p.EnvVar - } // Read enabled/disabled state from ConfigMap llmsRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath, @@ -175,8 +185,29 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { if err != nil { return nil, err } + + // Read Secret to check which API keys are set + secretRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath, + "get", "secret", secretName, "-n", namespace, "-o", "json") + if err != nil { + return nil, err + } + + return buildProviderStatus(available, []byte(llmsRaw), []byte(secretRaw)) +} + +// buildProviderStatus is the pure logic for building provider status from raw data. +// available: providers discovered from the llmspy pod +// llmsJSON: the llms.json content from the ConfigMap +// secretJSON: the full Secret JSON (with base64-encoded .data) +func buildProviderStatus(available []ProviderInfo, llmsJSON, secretJSON []byte) (map[string]ProviderStatus, error) { + envKeyByProvider := make(map[string]string) + for _, p := range available { + envKeyByProvider[p.ID] = p.EnvVar + } + var llmsConfig map[string]interface{} - if err := json.Unmarshal([]byte(llmsRaw), &llmsConfig); err != nil { + if err := json.Unmarshal(llmsJSON, &llmsConfig); err != nil { return nil, fmt.Errorf("failed to parse llms.json from ConfigMap: %w", err) } @@ -199,16 +230,11 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { } } - // Read Secret to check which API keys are set - secretRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath, - "get", "secret", secretName, "-n", namespace, "-o", "json") - if err != nil { - return nil, err - } + // Parse Secret var secret struct { Data map[string]string `json:"data"` } - if err := json.Unmarshal([]byte(secretRaw), &secret); err != nil { + if err := json.Unmarshal(secretJSON, &secret); err != nil { return nil, fmt.Errorf("failed to parse llms secret: %w", err) } @@ -241,45 +267,18 @@ func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) { // sets providers..enabled = true, and patches the ConfigMap back. func enableProviderInConfigMap(kubectlBinary, kubeconfigPath, provider string) error { // Read current llms.json from ConfigMap - var stdout bytes.Buffer - cmd := exec.Command(kubectlBinary, "get", "configmap", configMapName, - "-n", namespace, "-o", "jsonpath={.data.llms\\.json}") - cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) - cmd.Stdout = &stdout - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to read ConfigMap: %w\n%s", err, stderr.String()) - } - - // Parse JSON - var llmsConfig map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &llmsConfig); err != nil { - return fmt.Errorf("failed to parse llms.json: %w", err) - } - - // Set providers..enabled = true - providers, ok := llmsConfig["providers"].(map[string]interface{}) - if !ok { - providers = make(map[string]interface{}) - llmsConfig["providers"] = providers - } - - providerCfg, ok := providers[provider].(map[string]interface{}) - if !ok { - providerCfg = make(map[string]interface{}) - providers[provider] = providerCfg + raw, err := kubectlOutput(kubectlBinary, kubeconfigPath, + "get", "configmap", configMapName, "-n", namespace, "-o", "jsonpath={.data.llms\\.json}") + if err != nil { + return fmt.Errorf("failed to read ConfigMap: %w", err) } - providerCfg["enabled"] = true - // Marshal back to JSON - updated, err := json.Marshal(llmsConfig) + updated, err := patchLLMsJSON([]byte(raw), provider) if err != nil { - return fmt.Errorf("failed to marshal llms.json: %w", err) + return err } - // Patch ConfigMap - // Use strategic merge patch with the new llms.json + // Build ConfigMap patch patchData := map[string]interface{}{ "data": map[string]string{ "llms.json": string(updated), @@ -295,6 +294,30 @@ func enableProviderInConfigMap(kubectlBinary, kubeconfigPath, provider string) e "-p", string(patchJSON), "--type=merge") } +// patchLLMsJSON takes raw llms.json content and returns updated JSON +// with providers..enabled = true. +func patchLLMsJSON(llmsJSON []byte, provider string) ([]byte, error) { + var llmsConfig map[string]interface{} + if err := json.Unmarshal(llmsJSON, &llmsConfig); err != nil { + return nil, fmt.Errorf("failed to parse llms.json: %w", err) + } + + providers, ok := llmsConfig["providers"].(map[string]interface{}) + if !ok { + providers = make(map[string]interface{}) + llmsConfig["providers"] = providers + } + + providerCfg, ok := providers[provider].(map[string]interface{}) + if !ok { + providerCfg = make(map[string]interface{}) + providers[provider] = providerCfg + } + providerCfg["enabled"] = true + + return json.Marshal(llmsConfig) +} + // kubectl runs a kubectl command with the given kubeconfig and returns any error. func kubectl(binary, kubeconfig string, args ...string) error { cmd := exec.Command(binary, args...) diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 00000000..24f93cb3 --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,369 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestParseProviderEnvKey(t *testing.T) { + tests := []struct { + name string + provider string + output string + want string + wantErr bool + }{ + { + name: "anthropic", + provider: "anthropic", + output: "ANTHROPIC_API_KEY\n", + want: "ANTHROPIC_API_KEY", + }, + { + name: "zai with trailing whitespace", + provider: "zai", + output: " ZHIPU_API_KEY \n", + want: "ZHIPU_API_KEY", + }, + { + name: "empty output means unknown provider", + provider: "nosuchprovider", + output: "", + wantErr: true, + }, + { + name: "whitespace-only output means unknown provider", + provider: "nosuchprovider", + output: " \n ", + wantErr: true, + }, + { + name: "openai", + provider: "openai", + output: "OPENAI_API_KEY", + want: "OPENAI_API_KEY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseProviderEnvKey(tt.provider, tt.output) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseAvailableProviders(t *testing.T) { + tests := []struct { + name string + output string + want []ProviderInfo + }{ + { + name: "empty output", + output: "", + want: nil, + }, + { + name: "whitespace only", + output: " \n ", + want: nil, + }, + { + name: "single provider", + output: "anthropic\tAnthropic\tANTHROPIC_API_KEY\n", + want: []ProviderInfo{ + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + }, + }, + { + name: "multiple providers sorted", + output: "anthropic\tAnthropic\tANTHROPIC_API_KEY\n" + + "openai\tOpenAI\tOPENAI_API_KEY\n" + + "zai\tZ.AI\tZHIPU_API_KEY\n", + want: []ProviderInfo{ + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + {ID: "openai", Name: "OpenAI", EnvVar: "OPENAI_API_KEY"}, + {ID: "zai", Name: "Z.AI", EnvVar: "ZHIPU_API_KEY"}, + }, + }, + { + name: "malformed line skipped", + output: "badline\n" + "anthropic\tAnthropic\tANTHROPIC_API_KEY\n", + want: []ProviderInfo{ + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + }, + }, + { + name: "tab in name preserved", + output: "deepseek\tDeepSeek\tDEEPSEEK_API_KEY\n", + want: []ProviderInfo{ + {ID: "deepseek", Name: "DeepSeek", EnvVar: "DEEPSEEK_API_KEY"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseAvailableProviders(tt.output) + if len(got) != len(tt.want) { + t.Fatalf("got %d providers, want %d", len(got), len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("provider[%d]: got %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestBuildProviderStatus(t *testing.T) { + t.Run("basic status with cloud provider key set", func(t *testing.T) { + available := []ProviderInfo{ + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + {ID: "openai", Name: "OpenAI", EnvVar: "OPENAI_API_KEY"}, + {ID: "zai", Name: "Z.AI", EnvVar: "ZHIPU_API_KEY"}, + } + + llmsJSON := []byte(`{ + "providers": { + "ollama": {"enabled": true}, + "anthropic": {"enabled": true}, + "openai": {"enabled": false}, + "zai": {"enabled": true} + } + }`) + + // Secret .data values are base64 in real k8s, but our code just checks + // if the key exists and the value is non-empty (the cross-reference uses + // the raw string from the JSON — k8s returns base64 in .data). + secretJSON := []byte(`{ + "data": { + "ANTHROPIC_API_KEY": "c2stYW50LXh4eA==", + "OPENAI_API_KEY": "", + "ZHIPU_API_KEY": "ZWU1NjM5Nzk=" + } + }`) + + status, err := buildProviderStatus(available, llmsJSON, secretJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Ollama: enabled, always has key + if s := status["ollama"]; !s.Enabled || !s.HasAPIKey { + t.Errorf("ollama: got enabled=%t hasKey=%t, want enabled=true hasKey=true", s.Enabled, s.HasAPIKey) + } + + // Anthropic: enabled, key set + if s := status["anthropic"]; !s.Enabled || !s.HasAPIKey || s.EnvVar != "ANTHROPIC_API_KEY" { + t.Errorf("anthropic: got %+v, want enabled=true hasKey=true envVar=ANTHROPIC_API_KEY", s) + } + + // OpenAI: disabled, key empty + if s := status["openai"]; s.Enabled || s.HasAPIKey || s.EnvVar != "OPENAI_API_KEY" { + t.Errorf("openai: got %+v, want enabled=false hasKey=false envVar=OPENAI_API_KEY", s) + } + + // Z.AI: enabled, key set + if s := status["zai"]; !s.Enabled || !s.HasAPIKey || s.EnvVar != "ZHIPU_API_KEY" { + t.Errorf("zai: got %+v, want enabled=true hasKey=true envVar=ZHIPU_API_KEY", s) + } + }) + + t.Run("ollama injected when missing from configmap", func(t *testing.T) { + available := []ProviderInfo{ + {ID: "anthropic", Name: "Anthropic", EnvVar: "ANTHROPIC_API_KEY"}, + } + llmsJSON := []byte(`{"providers":{"anthropic":{"enabled":false}}}`) + secretJSON := []byte(`{"data":{}}`) + + status, err := buildProviderStatus(available, llmsJSON, secretJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if s, ok := status["ollama"]; !ok || !s.Enabled || !s.HasAPIKey { + t.Errorf("ollama should be injected as enabled with key; got %+v, ok=%t", s, ok) + } + }) + + t.Run("provider in configmap but not in available list gets no env var", func(t *testing.T) { + available := []ProviderInfo{} // no providers discovered + llmsJSON := []byte(`{"providers":{"mystery":{"enabled":true}}}`) + secretJSON := []byte(`{"data":{}}`) + + status, err := buildProviderStatus(available, llmsJSON, secretJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if s := status["mystery"]; !s.Enabled || s.EnvVar != "" { + t.Errorf("mystery: got %+v, want enabled=true envVar=''", s) + } + }) + + t.Run("empty providers section", func(t *testing.T) { + llmsJSON := []byte(`{}`) + secretJSON := []byte(`{"data":{}}`) + + status, err := buildProviderStatus(nil, llmsJSON, secretJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Only ollama (injected) + if len(status) != 1 { + t.Errorf("expected 1 provider (ollama), got %d", len(status)) + } + }) + + t.Run("invalid llms json", func(t *testing.T) { + _, err := buildProviderStatus(nil, []byte(`not json`), []byte(`{"data":{}}`)) + if err == nil { + t.Fatal("expected error for invalid llms.json") + } + }) + + t.Run("invalid secret json", func(t *testing.T) { + _, err := buildProviderStatus(nil, []byte(`{}`), []byte(`not json`)) + if err == nil { + t.Fatal("expected error for invalid secret JSON") + } + }) +} + +func TestPatchLLMsJSON(t *testing.T) { + t.Run("enable existing disabled provider", func(t *testing.T) { + input := []byte(`{"providers":{"anthropic":{"enabled":false},"ollama":{"enabled":true}}}`) + + got, err := patchLLMsJSON(input, "anthropic") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + providers := result["providers"].(map[string]interface{}) + anthropic := providers["anthropic"].(map[string]interface{}) + if anthropic["enabled"] != true { + t.Errorf("anthropic.enabled = %v, want true", anthropic["enabled"]) + } + + // Ollama should be untouched + ollama := providers["ollama"].(map[string]interface{}) + if ollama["enabled"] != true { + t.Errorf("ollama.enabled = %v, want true (untouched)", ollama["enabled"]) + } + }) + + t.Run("enable new provider not in config", func(t *testing.T) { + input := []byte(`{"providers":{"ollama":{"enabled":true}}}`) + + got, err := patchLLMsJSON(input, "zai") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + providers := result["providers"].(map[string]interface{}) + zai := providers["zai"].(map[string]interface{}) + if zai["enabled"] != true { + t.Errorf("zai.enabled = %v, want true", zai["enabled"]) + } + }) + + t.Run("create providers section if missing", func(t *testing.T) { + input := []byte(`{"version":"1.0"}`) + + got, err := patchLLMsJSON(input, "deepseek") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + // version preserved + if result["version"] != "1.0" { + t.Errorf("version lost: got %v", result["version"]) + } + + providers := result["providers"].(map[string]interface{}) + ds := providers["deepseek"].(map[string]interface{}) + if ds["enabled"] != true { + t.Errorf("deepseek.enabled = %v, want true", ds["enabled"]) + } + }) + + t.Run("preserves other provider fields", func(t *testing.T) { + input := []byte(`{"providers":{"anthropic":{"enabled":false,"customField":"keep"}}}`) + + got, err := patchLLMsJSON(input, "anthropic") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + providers := result["providers"].(map[string]interface{}) + anthropic := providers["anthropic"].(map[string]interface{}) + if anthropic["enabled"] != true { + t.Errorf("enabled = %v, want true", anthropic["enabled"]) + } + if anthropic["customField"] != "keep" { + t.Errorf("customField = %v, want 'keep'", anthropic["customField"]) + } + }) + + t.Run("invalid json input", func(t *testing.T) { + _, err := patchLLMsJSON([]byte(`{bad`), "anthropic") + if err == nil { + t.Fatal("expected error for invalid JSON") + } + }) + + t.Run("idempotent enable", func(t *testing.T) { + input := []byte(`{"providers":{"anthropic":{"enabled":true}}}`) + + got, err := patchLLMsJSON(input, "anthropic") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(got, &result); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + providers := result["providers"].(map[string]interface{}) + anthropic := providers["anthropic"].(map[string]interface{}) + if anthropic["enabled"] != true { + t.Errorf("enabled = %v, want true", anthropic["enabled"]) + } + }) +} From ceaa1ab2951d05782ae9fca7e4c24c97fb1375ff Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 11:20:38 +0400 Subject: [PATCH 3/8] Add Z.AI integration test for dynamic provider discovery Adds TestIntegration_ZaiInference that exercises a provider NOT in the old hardcoded map, proving zero-code-change provider support. Uses glm-4-flash via llmspy routing with ZHIPU_API_KEY from .env. --- internal/openclaw/integration_test.go | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index cd723874..b7988e08 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -485,6 +485,46 @@ func TestIntegration_OpenAIInference(t *testing.T) { t.Logf("OpenAI response: %s", reply) } +func TestIntegration_ZaiInference(t *testing.T) { + cfg := requireCluster(t) + apiKey := requireEnvKey(t, "ZHIPU_API_KEY") + + const id = "test-zai" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + // Configure llmspy gateway via obol model setup — this provider was NOT in + // the old hardcoded map, so it only works with dynamic provider discovery. + t.Log("configuring llmspy via: obol model setup --provider zai") + obolRun(t, cfg, "model", "setup", "--provider", "zai", "--api-key", apiKey) + + cloud := &CloudProviderInfo{ + Name: "zai", + APIKey: apiKey, + ModelID: "glm-4-flash", + Display: "GLM-4 Flash", + } + + // Scaffold cloud overlay + deploy via obol openclaw sync + t.Logf("scaffolding OpenClaw instance %q with Z.AI via llmspy", id) + scaffoldCloudInstance(t, cfg, id, cloud) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + waitForPodReady(t, cfg, namespace) + + token := getGatewayToken(t, cfg, id) + t.Logf("retrieved gateway token (%d chars)", len(token)) + + baseURL := portForward(t, cfg, namespace) + agentModel := "ollama/glm-4-flash" // routed through llmspy + t.Logf("testing inference with model %s at %s", agentModel, baseURL) + + reply := chatCompletion(t, baseURL, agentModel, token) + t.Logf("Z.AI response: %s", reply) +} + func TestIntegration_MultiInstance(t *testing.T) { cfg := requireCluster(t) models := requireOllama(t) From 0f16d5e95f489f7e87a0712d185182615e8fbcd7 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 12:20:32 +0400 Subject: [PATCH 4/8] Add rich embedded skills and full test coverage Replace the minimal ethereum skill with three production-ready skills: - obol-blockchain: Ethereum JSON-RPC via eRPC with rpc.py helper, ERC-20 reference, contract addresses, ENS, gas estimation - obol-k8s: Kubernetes cluster diagnostics via ServiceAccount API with kube.py helper for pod/service/event introspection - obol-dvt: Obol DVT cluster monitoring with API examples for validator effectiveness, operator auditing, and exit coordination Keeps the hello skill as-is for smoke testing. Test coverage spans every layer of the skills pipeline: - Unit: embed discovery, copy, skip-existing, volume path, staging, injection, no-op without skills - Integration: staging on sync, pod visibility via kubectl exec, skills sync --from, idempotent re-sync, skill-through-inference (agent references loaded skills), Python smoke tests piped into pod --- internal/embed/embed_skills_test.go | 99 +++++ internal/embed/skills/ethereum/SKILL.md | 51 --- .../embed/skills/obol-blockchain/SKILL.md | 194 ++++++++++ .../references/common-contracts.md | 66 ++++ .../references/erc20-methods.md | 91 +++++ .../skills/obol-blockchain/scripts/rpc.py | 192 ++++++++++ internal/embed/skills/obol-dvt/SKILL.md | 247 ++++++++++++ .../obol-dvt/references/api-examples.md | 278 ++++++++++++++ internal/embed/skills/obol-k8s/SKILL.md | 148 +++++++ .../embed/skills/obol-k8s/scripts/kube.py | 297 +++++++++++++++ internal/openclaw/integration_test.go | 360 +++++++++++++++++- internal/openclaw/skills_injection_test.go | 112 ++++++ tests/skills_smoke_test.py | 291 ++++++++++++++ 13 files changed, 2365 insertions(+), 61 deletions(-) create mode 100644 internal/embed/embed_skills_test.go delete mode 100644 internal/embed/skills/ethereum/SKILL.md create mode 100644 internal/embed/skills/obol-blockchain/SKILL.md create mode 100644 internal/embed/skills/obol-blockchain/references/common-contracts.md create mode 100644 internal/embed/skills/obol-blockchain/references/erc20-methods.md create mode 100644 internal/embed/skills/obol-blockchain/scripts/rpc.py create mode 100644 internal/embed/skills/obol-dvt/SKILL.md create mode 100644 internal/embed/skills/obol-dvt/references/api-examples.md create mode 100644 internal/embed/skills/obol-k8s/SKILL.md create mode 100644 internal/embed/skills/obol-k8s/scripts/kube.py create mode 100644 internal/openclaw/skills_injection_test.go create mode 100644 tests/skills_smoke_test.py diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go new file mode 100644 index 00000000..fe6cf4fe --- /dev/null +++ b/internal/embed/embed_skills_test.go @@ -0,0 +1,99 @@ +package embed + +import ( + "os" + "path/filepath" + "sort" + "testing" +) + +func TestGetEmbeddedSkillNames(t *testing.T) { + names, err := GetEmbeddedSkillNames() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + sort.Strings(names) + + if len(names) != len(want) { + t.Fatalf("got %d skills %v, want %d %v", len(names), names, len(want), want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("skill[%d] = %q, want %q", i, names[i], want[i]) + } + } +} + +func TestCopySkills(t *testing.T) { + destDir := t.TempDir() + + if err := CopySkills(destDir); err != nil { + t.Fatalf("CopySkills: %v", err) + } + + // Every skill must have a SKILL.md + skills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + for _, skill := range skills { + skillMD := filepath.Join(destDir, skill, "SKILL.md") + info, err := os.Stat(skillMD) + if err != nil { + t.Errorf("%s/SKILL.md: %v", skill, err) + continue + } + if info.Size() == 0 { + t.Errorf("%s/SKILL.md is empty", skill) + } + } + + // obol-blockchain must have scripts/rpc.py and references/ + for _, sub := range []string{ + "obol-blockchain/scripts/rpc.py", + "obol-blockchain/references/erc20-methods.md", + "obol-blockchain/references/common-contracts.md", + } { + if _, err := os.Stat(filepath.Join(destDir, sub)); err != nil { + t.Errorf("missing %s: %v", sub, err) + } + } + + // obol-k8s must have scripts/kube.py + if _, err := os.Stat(filepath.Join(destDir, "obol-k8s", "scripts", "kube.py")); err != nil { + t.Errorf("missing obol-k8s/scripts/kube.py: %v", err) + } + + // obol-dvt must have references/api-examples.md + if _, err := os.Stat(filepath.Join(destDir, "obol-dvt", "references", "api-examples.md")); err != nil { + t.Errorf("missing obol-dvt/references/api-examples.md: %v", err) + } +} + +func TestCopySkillsSkipsExisting(t *testing.T) { + destDir := t.TempDir() + + // Pre-create a skill directory with custom content + customDir := filepath.Join(destDir, "hello") + if err := os.MkdirAll(customDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + customFile := filepath.Join(customDir, "custom.txt") + if err := os.WriteFile(customFile, []byte("user content"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // CopySkills should still succeed (it copies all files, including into existing dirs) + if err := CopySkills(destDir); err != nil { + t.Fatalf("CopySkills: %v", err) + } + + // Custom file should still be present + if _, err := os.Stat(customFile); err != nil { + t.Errorf("custom file was removed: %v", err) + } + + // But SKILL.md should also have been copied + if _, err := os.Stat(filepath.Join(customDir, "SKILL.md")); err != nil { + t.Errorf("SKILL.md not copied alongside custom content: %v", err) + } +} diff --git a/internal/embed/skills/ethereum/SKILL.md b/internal/embed/skills/ethereum/SKILL.md deleted file mode 100644 index 14772da2..00000000 --- a/internal/embed/skills/ethereum/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: ethereum -description: Ethereum JSON-RPC access via the Obol Stack eRPC gateway ---- - -# Ethereum RPC via eRPC - -Query Ethereum networks through the Obol Stack's eRPC gateway. - -## eRPC Gateway - -The eRPC service provides a unified JSON-RPC proxy for all connected Ethereum networks. The base URL is always `http://erpc.erpc.svc.cluster.local:4000` inside the cluster. - -- **Config/discovery**: `GET http://erpc.erpc.svc.cluster.local:4000/` — returns the eRPC configuration schema including all connected networks and their endpoints -- **RPC endpoint pattern**: `http://erpc.erpc.svc.cluster.local:4000/rpc/` - -## Discovering Connected Networks - -Fetch the eRPC root config to discover which networks are available: - -```bash -curl -s http://erpc.erpc.svc.cluster.local:4000/ -``` - -Parse the response to find project IDs — each project ID is a `` you can query. - -## JSON-RPC Queries - -All queries use standard Ethereum JSON-RPC. Send POST requests to `http://erpc.erpc.svc.cluster.local:4000/rpc/`. - -### Common read methods -- `eth_blockNumber` — latest block number -- `eth_syncing` — sync status (false if synced) -- `eth_getBalance` — account balance (params: address, block) -- `eth_getBlockByNumber` — block details (params: block number, full txs bool) -- `eth_getTransactionReceipt` — transaction receipt (params: tx hash) -- `eth_call` — read-only contract call (params: call object, block) -- `net_peerCount` — connected peer count -- `eth_gasPrice` — current gas price -- `eth_chainId` — chain identifier - -### Example: get latest block number on mainnet -```bash -curl -s http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet \ - -X POST -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' -``` - -## Limitations -- Read-only queries only — no write transactions (eth_sendTransaction, eth_sendRawTransaction) -- Network availability depends on what the user has installed via `obol network install` diff --git a/internal/embed/skills/obol-blockchain/SKILL.md b/internal/embed/skills/obol-blockchain/SKILL.md new file mode 100644 index 00000000..8943e518 --- /dev/null +++ b/internal/embed/skills/obol-blockchain/SKILL.md @@ -0,0 +1,194 @@ +--- +name: obol-blockchain +description: "Blockchain RPC and Ethereum operations via local eRPC gateway. Use when: querying blocks, balances, transactions, contract state, token balances, ENS names, gas prices, or any eth_* method. Handles JSON-RPC encoding, hex conversion, and ABI decoding. Routes all queries through the in-cluster eRPC load balancer. NOT for: sending transactions, deploying contracts, DVT/validator operations (use obol-dvt), or Kubernetes operations (use obol-k8s)." +metadata: { "openclaw": { "emoji": "⛓️", "requires": { "bins": ["curl", "python3"] } } } +--- + +# Obol Blockchain + +Query Ethereum blockchain state through the local eRPC gateway. Covers raw JSON-RPC methods, ERC-20 token operations, ENS resolution, gas estimation, and transaction analysis. + +## When to Use + +- "What's the latest block number?" +- "Check balance of 0x..." +- "Read contract state / call a view function" +- "What are the token balances for this address?" +- "Resolve an ENS name" +- "Estimate gas for this call" +- "Look up a transaction receipt" +- Any `eth_*`, `net_*`, or `web3_*` JSON-RPC method + +## When NOT to Use + +- Sending transactions or signing — no private keys available (read-only) +- Deploying contracts — no write access +- DVT cluster monitoring — use `obol-dvt` +- Kubernetes pod health — use `obol-k8s` + +## Environment + +The eRPC gateway supports two URL path formats: + +``` +Alias: http://erpc.erpc.svc.cluster.local:4000/rpc/{alias} e.g. /rpc/mainnet +Explicit: http://erpc.erpc.svc.cluster.local:4000/rpc/evm/{chainId} e.g. /rpc/evm/1 +``` + +`mainnet` alias is always configured. Other network aliases (e.g. `hoodi`) are only available if that Ethereum network has been installed. As a fallback, you can use the explicit `evm/{chainId}` format — for example `/rpc/evm/560048` for Hoodi. + +To discover which networks are currently connected to eRPC: + +```bash +curl -s http://erpc.erpc.svc.cluster.local:4000/ | python3 -m json.tool +``` + +Each project ID in the response is a network alias you can query via `/rpc/{alias}`. + +The helper script defaults to `mainnet`. Override with `--network` flag or `ERPC_NETWORK` env var. The script accepts both aliases (`mainnet`) and explicit paths (`evm/560048`). + +## Quick Start + +```bash +# Block number (mainnet default) +python3 scripts/rpc.py eth_blockNumber + +# Block number on hoodi testnet (use evm/chainId if alias not configured) +python3 scripts/rpc.py --network hoodi eth_blockNumber +python3 scripts/rpc.py --network evm/560048 eth_blockNumber + +# Balance (returns ETH) +python3 scripts/rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + +# Gas price (returns Gwei) +python3 scripts/rpc.py eth_gasPrice + +# Chain ID +python3 scripts/rpc.py eth_chainId + +# Contract read (ERC-20 totalSupply) +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd +``` + +## JSON-RPC Methods + +| Method | Params | Returns | +|--------|--------|---------| +| `eth_blockNumber` | none | Latest block number | +| `eth_getBalance` | `address [block]` | Balance in wei (script converts to ETH) | +| `eth_gasPrice` | none | Gas price in wei (script converts to Gwei) | +| `eth_chainId` | none | Chain ID (1=mainnet, 560048=hoodi) | +| `eth_getBlockByNumber` | `blockNum includeTxs` | Block data | +| `eth_getTransactionByHash` | `txHash` | Transaction details | +| `eth_getTransactionReceipt` | `txHash` | Receipt with logs and status | +| `eth_call` | `to data [block]` | Contract read result | +| `eth_estimateGas` | `to data [from] [value]` | Gas estimate | +| `eth_getLogs` | `fromBlock toBlock [address] [topic0]` | Event logs | +| `net_version` | none | Network ID | + +## Token Operations + +Read ERC-20 token state using `eth_call` with the contract address and function selector. + +### Check Token Balance + +```bash +# balanceOf(address) selector: 0x70a08231 +# Pad address to 32 bytes (left-pad with zeros, remove 0x prefix) +python3 scripts/rpc.py eth_call \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` + +### Get Token Info + +```bash +# name() -> 0x06fdde03 +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x06fdde03 + +# symbol() -> 0x95d89b41 +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x95d89b41 + +# decimals() -> 0x313ce567 +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x313ce567 + +# totalSupply() -> 0x18160ddd +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd +``` + +See `references/erc20-methods.md` for the complete function selector reference and ABI encoding guide. + +## ENS Resolution (Mainnet Only) + +ENS names resolve through the ENS registry at `0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e`. + +```bash +# Step 1: Get resolver for a name (namehash required) +# Step 2: Call resolver.addr(namehash) to get the address + +# For common names, use the public resolver directly: +# PublicResolver: 0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63 +# addr(bytes32 node) selector: 0x3b3b57de +``` + +ENS resolution requires computing the namehash using **Keccak-256** (Ethereum's hash function). + +**Warning:** Python's `hashlib.sha3_256` is NIST SHA-3, NOT Keccak-256. They use different internal padding and produce different outputs. Do not use `hashlib.sha3_256` for ENS namehash — it will return wrong results. + +Computing namehash correctly requires a Keccak-256 library (e.g., `pysha3`, `pycryptodome`, or `ethers.js`). Since these aren't available in the pod, ENS resolution is limited to names with known namehashes or external lookup services. + +## Gas Estimation + +```bash +# Estimate gas for a transfer +python3 scripts/rpc.py eth_estimateGas \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + 0xa9059cbb000000000000000000000000... + +# Current gas price +python3 scripts/rpc.py eth_gasPrice + +# Total cost estimate: gasEstimate * gasPrice +``` + +## Transaction Analysis + +```bash +# Get transaction details +python3 scripts/rpc.py eth_getTransactionByHash 0xabc123... + +# Get receipt with logs +python3 scripts/rpc.py eth_getTransactionReceipt 0xabc123... +``` + +Receipt fields: `status` (0x1=success, 0x0=revert), `gasUsed`, `logs[]` (events emitted). + +## Direct curl + +When the helper script doesn't cover a method or you need custom params: + +```bash +# Mainnet +curl -s -X POST "$ERPC_URL/mainnet" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(int(r['result'],16) if 'result' in r else r)" + +# Hoodi testnet (alias — requires hoodi network installed) +curl -s -X POST "$ERPC_URL/hoodi" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' + +# Hoodi testnet (explicit chain ID — always works) +curl -s -X POST "$ERPC_URL/evm/560048" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' +``` + +## Constraints + +- **Read-only** — no private keys, no transaction signing, no state mutations +- **Local routing** — always use eRPC (`$ERPC_URL`), never call external RPC providers directly +- **Hex encoding** — JSON-RPC uses hex for numbers and bytes; the helper script converts common cases +- **Block parameter** — `latest` (default), `earliest`, `pending`, or hex block number +- See `references/common-contracts.md` for well-known contract addresses diff --git a/internal/embed/skills/obol-blockchain/references/common-contracts.md b/internal/embed/skills/obol-blockchain/references/common-contracts.md new file mode 100644 index 00000000..9227956d --- /dev/null +++ b/internal/embed/skills/obol-blockchain/references/common-contracts.md @@ -0,0 +1,66 @@ +# Common Contract Addresses + +## Mainnet (Chain ID: 1) + +### Tokens + +| Token | Address | Decimals | +|-------|---------|----------| +| WETH | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | 18 | +| USDC | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | 6 | +| USDT | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | 6 | +| DAI | `0x6B175474E89094C44Da98b954EedeAC495271d0F` | 18 | +| WBTC | `0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599` | 8 | +| stETH | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | 18 | +| wstETH | `0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0` | 18 | + +### DeFi Protocols + +| Protocol | Contract | Address | +|----------|----------|---------| +| Uniswap V2 | Router | `0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D` | +| Uniswap V3 | Router | `0xE592427A0AEce92De3Edee1F18E0157C05861564` | +| Uniswap V3 | Quoter | `0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6` | + +### ENS + +| Contract | Address | +|----------|---------| +| ENS Registry | `0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e` | +| Public Resolver | `0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63` | +| Reverse Registrar | `0xa58E81fe9b61B5c3fE2AFD33CF304c454AbFc7Cb` | + +### Obol Network + +| Contract | Address | +|----------|---------| +| Obol Token (OBOL) | `0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7` | + +## Hoodi Testnet (Chain ID: 560048) + +Hoodi is a newer testnet. Contract addresses may differ from mainnet. Use `eth_chainId` to confirm you're on the right network before querying. + +## Quick Queries + +### Check if an address is a contract + +```bash +python3 scripts/rpc.py eth_getCode 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 latest +# Returns bytecode if contract, "0x" if EOA +``` + +### Get USDC balance + +```bash +python3 scripts/rpc.py eth_call \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + 0x70a08231000000000000000000000000 +# Result is in 6 decimals: divide by 1e6 +``` + +### Get ETH balance + +```bash +python3 scripts/rpc.py eth_getBalance 0x
+# Result is auto-converted to ETH +``` diff --git a/internal/embed/skills/obol-blockchain/references/erc20-methods.md b/internal/embed/skills/obol-blockchain/references/erc20-methods.md new file mode 100644 index 00000000..9b9e109f --- /dev/null +++ b/internal/embed/skills/obol-blockchain/references/erc20-methods.md @@ -0,0 +1,91 @@ +# ERC-20 Function Selectors & ABI Encoding + +## Standard ERC-20 Functions + +| Function | Selector | Params | Returns | +|----------|----------|--------|---------| +| `name()` | `0x06fdde03` | none | string | +| `symbol()` | `0x95d89b41` | none | string | +| `decimals()` | `0x313ce567` | none | uint8 | +| `totalSupply()` | `0x18160ddd` | none | uint256 | +| `balanceOf(address)` | `0x70a08231` | owner address | uint256 | +| `transfer(address,uint256)` | `0xa9059cbb` | to, amount | bool | +| `approve(address,uint256)` | `0x095ea7b3` | spender, amount | bool | +| `allowance(address,address)` | `0xdd62ed3e` | owner, spender | uint256 | +| `transferFrom(address,address,uint256)` | `0x23b872dd` | from, to, amount | bool | + +## Event Signatures + +| Event | Topic0 | +|-------|--------| +| `Transfer(address,address,uint256)` | `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef` | +| `Approval(address,address,uint256)` | `0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925` | + +## ABI Encoding Guide + +### Address Encoding + +Addresses are left-padded to 32 bytes (64 hex chars): + +``` +0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +becomes: +000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` + +### Building eth_call Data + +Concatenate the function selector (4 bytes) with encoded parameters (32 bytes each): + +``` +balanceOf(0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045): + +data = 0x70a08231 (selector) + + 000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 (address) + += 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` + +### Multiple Parameters + +``` +allowance(owner, spender): + +data = 0xdd62ed3e (selector) + + 000000000000000000000000 (owner) + + 000000000000000000000000 (spender) +``` + +### Decoding Return Values + +- **uint256**: Hex string, 32 bytes. Convert to decimal: `int(result, 16)` +- **bool**: `0x...01` = true, `0x...00` = false +- **string**: ABI-encoded with offset + length + data (complex, use python3) +- **address**: Last 20 bytes of 32-byte value + +### Decoding Token Amounts + +Always check `decimals()` first: + +```python +raw = int(result, 16) # raw balance from balanceOf +decimals = int(dec_result, 16) # from decimals() +balance = raw / (10 ** decimals) +``` + +Common decimals: USDC/USDT = 6, DAI/WETH = 18. + +## Example: Full Token Query + +```bash +# 1. Get token name +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x06fdde03 + +# 2. Get decimals +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x313ce567 + +# 3. Get balance for vitalik.eth +python3 scripts/rpc.py eth_call \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 +``` diff --git a/internal/embed/skills/obol-blockchain/scripts/rpc.py b/internal/embed/skills/obol-blockchain/scripts/rpc.py new file mode 100644 index 00000000..3a55fcec --- /dev/null +++ b/internal/embed/skills/obol-blockchain/scripts/rpc.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""JSON-RPC helper for Obol Stack eRPC gateway. + +Usage: + python3 rpc.py [param1] [param2] ... + python3 rpc.py --network hoodi [param1] ... + +Examples: + python3 rpc.py eth_blockNumber + python3 rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + python3 rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd + python3 rpc.py --network hoodi eth_blockNumber +""" + +import json +import os +import sys +import urllib.request + +# eRPC requires /rpc/{network} path. ERPC_URL is the base (without network). +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local:4000/rpc") +DEFAULT_NETWORK = os.environ.get("ERPC_NETWORK", "mainnet") + +# Methods that take no params +NO_PARAM_METHODS = {"eth_blockNumber", "eth_gasPrice", "eth_chainId", "net_version", "web3_clientVersion"} + + +def rpc_call(method, params=None, network=None): + """Send a JSON-RPC request and return the result.""" + net = network or DEFAULT_NETWORK + url = f"{ERPC_BASE}/{net}" + + payload = json.dumps({ + "jsonrpc": "2.0", + "method": method, + "params": params or [], + "id": 1, + }).encode() + + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + + if "error" in data: + code = data["error"].get("code", "?") + msg = data["error"].get("message", "unknown error") + print(f"RPC error {code}: {msg}", file=sys.stderr) + sys.exit(1) + + return data.get("result") + + +def hex_to_int(val): + """Convert hex string to int.""" + if isinstance(val, str) and val.startswith("0x"): + return int(val, 16) + return val + + +def format_result(method, result): + """Format result based on method for human readability.""" + if result is None: + print("null") + return + + if method == "eth_blockNumber": + block = hex_to_int(result) + print(f"Block: {block:,} (0x{block:x})") + + elif method == "eth_getBalance": + wei = hex_to_int(result) + eth = wei / 1e18 + print(f"Balance: {eth:.6f} ETH ({wei:,} wei)") + + elif method == "eth_gasPrice": + wei = hex_to_int(result) + gwei = wei / 1e9 + print(f"Gas price: {gwei:.2f} Gwei ({wei:,} wei)") + + elif method == "eth_chainId": + chain_id = hex_to_int(result) + names = {1: "mainnet", 560048: "hoodi", 11155111: "sepolia"} + name = names.get(chain_id, "unknown") + print(f"Chain ID: {chain_id} ({name})") + + elif method == "eth_estimateGas": + gas = hex_to_int(result) + print(f"Gas estimate: {gas:,}") + + elif isinstance(result, str) and result.startswith("0x") and len(result) <= 66: + # Short hex result — show both hex and decimal + val = hex_to_int(result) + print(f"Result: {val} (0x{val:x})") + + elif isinstance(result, (dict, list)): + print(json.dumps(result, indent=2)) + + else: + print(result) + + +def build_params(method, args): + """Build JSON-RPC params array from CLI arguments.""" + if method in NO_PARAM_METHODS: + return [] + + if method == "eth_getBalance": + addr = args[0] if args else "0x0" + block = args[1] if len(args) > 1 else "latest" + return [addr, block] + + if method == "eth_getBlockByNumber": + block = args[0] if args else "latest" + include_txs = args[1].lower() == "true" if len(args) > 1 else False + return [block, include_txs] + + if method in ("eth_getTransactionByHash", "eth_getTransactionReceipt"): + return [args[0]] if args else [] + + if method == "eth_call": + to_addr = args[0] if args else "0x0" + data = args[1] if len(args) > 1 else "0x" + block = args[2] if len(args) > 2 else "latest" + return [{"to": to_addr, "data": data}, block] + + if method == "eth_estimateGas": + to_addr = args[0] if args else "0x0" + data = args[1] if len(args) > 1 else "0x" + obj = {"to": to_addr, "data": data} + if len(args) > 2: + obj["from"] = args[2] + if len(args) > 3: + obj["value"] = args[3] + return [obj] + + if method == "eth_getLogs": + from_block = args[0] if args else "latest" + to_block = args[1] if len(args) > 1 else "latest" + log_filter = {"fromBlock": from_block, "toBlock": to_block} + if len(args) > 2: + log_filter["address"] = args[2] + if len(args) > 3: + log_filter["topics"] = [args[3]] + return [log_filter] + + # Fallback: pass args as-is + return list(args) + + +def main(): + argv = sys.argv[1:] + + # Parse --network flag + network = None + if "--network" in argv: + idx = argv.index("--network") + if idx + 1 < len(argv): + network = argv[idx + 1] + argv = argv[:idx] + argv[idx + 2:] + else: + print("Error: --network requires a value (mainnet, hoodi, sepolia)", file=sys.stderr) + sys.exit(1) + + if not argv: + net = network or DEFAULT_NETWORK + print(f"Usage: python3 rpc.py [--network NAME] [param1] [param2] ...") + print(f"\nEndpoint: {ERPC_BASE}/{net}") + print(f"Network: {net}") + print("\nCommon methods:") + print(" eth_blockNumber") + print(" eth_getBalance
[block]") + print(" eth_gasPrice") + print(" eth_chainId") + print(" eth_call [block]") + print(" eth_getLogs [address] [topic0]") + print(" eth_getTransactionReceipt ") + sys.exit(1) + + method = argv[0] + args = argv[1:] + params = build_params(method, args) + result = rpc_call(method, params, network=network) + format_result(method, result) + + +if __name__ == "__main__": + main() diff --git a/internal/embed/skills/obol-dvt/SKILL.md b/internal/embed/skills/obol-dvt/SKILL.md new file mode 100644 index 00000000..ac1a6836 --- /dev/null +++ b/internal/embed/skills/obol-dvt/SKILL.md @@ -0,0 +1,247 @@ +--- +name: obol-dvt +description: "Distributed Validator (DVT) cluster monitoring, operator management, and exit coordination via Obol Network API. Use when: querying DVT clusters, checking validator performance, investigating operator status, coordinating exits, or discussing Obol/Charon/DKG concepts. Uses mcporter MCP tools if configured, falls back to direct Obol API calls via curl. NOT for: creating clusters, running DKG, or submitting exits (write operations)." +metadata: { "openclaw": { "emoji": "🔱", "requires": { "bins": ["curl"] } } } +--- + +# Obol Distributed Validator (DV) Skill + +Query and monitor Distributed Validators on the Obol Network. Covers cluster health, operator management, exit coordination, and DVT concepts. + +--- + +## What is a Distributed Validator? + +A **Distributed Validator (DV)** is an Ethereum validator whose private key is never held +by a single party. Instead, the signing key is split across a group of **operators** using +threshold BLS cryptography. Any `threshold-of-N` operators must cooperate to produce a valid +signature — so the validator keeps attesting even if some operators go offline, and no single +operator can act maliciously on their own. + +Obol's open-source middleware is called **Charon**. Each operator runs a Charon client +alongside their validator client (e.g., Lighthouse, Teku). Charon handles the consensus +protocol between operators so the validator appears as a single validator to the beacon chain. + +| Term | Meaning | +|------|---------| +| **Cluster** | A group of N operators running DVs together | +| **Threshold** | Minimum operators needed to sign (e.g., 3-of-4) | +| **DKG** | Distributed Key Generation — operators collaboratively create the shared key without anyone seeing the full private key | +| **Cluster Definition** | Pre-DKG proposal: who the operators are, how many validators, which network | +| **Cluster Lock** | Post-DKG artifact: locked configuration + generated validator public keys; identified by `lock_hash` | +| **config_hash** | Hash of the cluster definition (pre-DKG); also embedded in the lock | +| **lock_hash** | Hash of the cluster lock (post-DKG); the primary identifier for a running cluster | +| **Operator** | An Ethereum address that participates in one or more DV clusters | +| **Techne** | Obol's operator reputation system: base > bronze > silver | +| **OWR** | Optimistic Withdrawal Recipient — a smart contract that splits validator rewards | + +--- + +## Cluster Lifecycle + +``` +[Cluster Definition] -> operators agree on config, sign T&Cs + | + [DKG Ceremony] -> Charon nodes exchange key shares; no full key ever assembled + | + [Cluster Lock] -> validator pubkeys generated; cluster is live on beacon chain + | + [Active Validators] -> attesting, proposing blocks, earning rewards + | + [Exit Coordination] -> operators sign exit messages; broadcast when threshold reached +``` + +When a user provides a `config_hash`, they are referring to something at or before DKG. +When they provide a `lock_hash`, the cluster has completed DKG and may have active validators. + +--- + +## API Access + +**Base URL:** `https://api.obol.tech` (public, no authentication needed) + +### Preferred: mcporter MCP tools + +If mcporter is configured with the obol-mcp server: + +```bash +mcporter call obol.obol_cluster_lock_by_hash lock_hash=0x4d6e7f8a... +mcporter call obol.obol_cluster_effectiveness lock_hash=0x4d6e7f8a... +``` + +Check availability: `mcporter list obol 2>/dev/null` + +### Fallback: Direct curl + +```bash +# Helper function for Obol API calls +obol_api() { + curl -s "https://api.obol.tech$1" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin),indent=2))" +} + +# Example +obol_api "/v1/lock/0x4d6e7f8a..." +``` + +--- + +## Tool Selection Guide + +### "I have a lock_hash" + +| Goal | API Endpoint | curl | +|------|-------------|------| +| Cluster config | `GET /v1/lock/{lockHash}` | `curl -s "https://api.obol.tech/v1/lock/0x..."` | +| Validator performance | `GET /v1/effectiveness/{lockHash}` | `curl -s "https://api.obol.tech/v1/effectiveness/0x..."` | +| Validator beacon states | `GET /v1/state/{lockHash}` | `curl -s "https://api.obol.tech/v1/state/0x..."` | +| Exit status summary | `GET /v1/exp/exit/status/summary/{lockHash}` | `curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..."` | +| Detailed exit status | `GET /v1/exp/exit/status/{lockHash}` | `curl -s "https://api.obol.tech/v1/exp/exit/status/0x..."` | + +### "I have a config_hash" + +| Goal | API Endpoint | curl | +|------|-------------|------| +| Pre-DKG definition | `GET /v1/definition/{configHash}` | `curl -s "https://api.obol.tech/v1/definition/0x..."` | +| Cluster lock (if DKG done) | `GET /v1/lock/configHash/{configHash}` | `curl -s "https://api.obol.tech/v1/lock/configHash/0x..."` | + +### "I have an operator address (0x...)" + +| Goal | API Endpoint | +|------|-------------| +| All clusters | `GET /v1/lock/operator/{address}` | +| Cluster definitions | `GET /v1/definition/operator/{address}` | +| Badges (Lido, EtherFi) | `GET /v1/address/badges/{address}` | +| Techne credential level | `GET /v1/address/techne/{address}` | +| Token incentives | `GET /v1/address/incentives/{network}/{address}` | +| T&Cs signed? | `GET /v1/termsAndConditions/{address}` | + +### "I want to explore a network (mainnet / holesky / sepolia)" + +| Goal | API Endpoint | +|------|-------------| +| All clusters | `GET /v1/lock/network/{network}` | +| Network statistics | `GET /v1/lock/network/summary/{network}` | +| Search clusters | `GET /v1/lock/search/{network}?q=...` | +| All operators | `GET /v1/address/network/{network}` | +| Search operators | `GET /v1/address/search/{network}?q=...` | + +### Other + +| Goal | API Endpoint | +|------|-------------| +| Migrateable validators | `GET /v1/address/migrateable-validators/{network}/{withdrawalAddress}` | +| OWR tranches | `GET /v1/owr/{network}/{address}` | +| API health | `GET /v1/_health` | + +--- + +## Common Workflows + +### Investigate a cluster's health + +```bash +# 1. Get cluster config +curl -s "https://api.obol.tech/v1/lock/0x4d6e7f8a..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +print(f'Cluster: {d.get(\"name\",\"?\")} on {d.get(\"network\",\"?\")}') +print(f'Threshold: {d.get(\"threshold\",\"?\")}-of-{len(d.get(\"operators\",[]))}') +print(f'Validators: {d.get(\"num_validators\",len(d.get(\"validators\",[])))}') +" + +# 2. Check effectiveness +curl -s "https://api.obol.tech/v1/effectiveness/0x4d6e7f8a..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +for v in d.get('effectiveness',[]): + eff = v.get('effectiveness',0) + pk = v.get('public_key','?')[:16] + status = 'healthy' if eff > 0.95 else 'degraded' if eff > 0.8 else 'CRITICAL' + print(f'{pk}... {eff:.3f} [{status}]') +" + +# 3. Check validator states +curl -s "https://api.obol.tech/v1/state/0x4d6e7f8a..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +for v in d.get('validators',[]): + bal = int(v.get('balance','0')) / 1e9 + print(f'{v[\"public_key\"][:16]}... {v.get(\"status\",\"?\")} {bal:.4f} ETH') +" +``` + +If effectiveness is low: one or more operators offline, misconfigured Charon, network latency, or a validator stuck in `exiting` state. + +### Coordinate a voluntary exit + +```bash +# 1. Exit status summary +curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +print(f'Ready to exit: {d.get(\"validators_ready_to_exit\",0)}/{d.get(\"total_validators\",0)}') +for op in d.get('operators',[]): + print(f' {op[\"address\"][:12]}... signed: {op.get(\"signed_exits\",0)}') +" +``` + +Exit is broadcast automatically once `threshold` operators have submitted their exit signature shares. + +### Audit an operator + +```bash +# Techne level +curl -s "https://api.obol.tech/v1/address/techne/0xAbCd..." | python3 -c " +import sys,json; d=json.load(sys.stdin); print(f'Level: {d.get(\"credential_level\",\"?\")}')" + +# Badges +curl -s "https://api.obol.tech/v1/address/badges/0xAbCd..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +badges = [b['type'] for b in d.get('badges',[])] +print(f'Badges: {badges if badges else \"none\"}')" + +# Cluster count +curl -s "https://api.obol.tech/v1/lock/operator/0xAbCd..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +clusters = d if isinstance(d,list) else d.get('items',[]) +print(f'Active in {len(clusters)} cluster(s)')" + +# T&Cs signed? +curl -s "https://api.obol.tech/v1/termsAndConditions/0xAbCd..." | python3 -c " +import sys,json; d=json.load(sys.stdin); print(f'T&Cs signed: {d}')" +``` + +--- + +## How to Talk About DVs + +**Do say:** +- "Your cluster has 4 operators with a 3-of-4 threshold, so it tolerates one operator going offline." +- "The cluster lock (identified by its `lock_hash`) is the source of truth after DKG." +- "Effectiveness of 0.95 means the validator is attesting in ~95% of expected slots." +- "Exit coordination requires threshold operators to submit their key shares of the exit message." + +**Avoid:** +- Saying the private key is "split into pieces" — it's threshold cryptography; no full key is ever assembled. +- Saying a cluster "fails" if one operator goes offline — it degrades gracefully until the threshold is not met. +- Confusing `config_hash` (pre-DKG) with `lock_hash` (post-DKG). + +## Identifier Formats + +- `lock_hash` and `config_hash`: hex strings starting with `0x`, typically 66 characters +- Operator `address`: standard Ethereum address, `0x` + 40 hex chars +- Validator `pubkey`: BLS public key, `0x` + 96 hex chars + +All identifiers are **case-sensitive** in Obol API calls. If a user provides an address without `0x`, remind them to include it. + +**Networks:** `mainnet` (real ETH), `hoodi` (staking/infra testnet, successor to holesky), `holesky` (legacy testnet), `sepolia` (secondary testnet) + +**Validator status values:** `active_ongoing`, `active_exiting`, `active_slashed`, `exited_unslashed`, `exited_slashed`, `withdrawal_possible`, `withdrawal_done`, `pending_*` + +## Examples + +For parameter shapes, response field reference, and example conversation patterns, see: +`references/api-examples.md` + +## Limitations + +- All API calls are **read-only** — creating clusters, running DKG, and submitting exits require authenticated POST endpoints +- Exit status endpoints are under `/v1/exp/` (experimental) — pagination is 1-indexed +- API rate limits apply; if timeouts occur, check `GET /v1/_health` first +- mcporter MCP integration requires the obol-mcp server to be installed (pip not available in pod currently) diff --git a/internal/embed/skills/obol-dvt/references/api-examples.md b/internal/embed/skills/obol-dvt/references/api-examples.md new file mode 100644 index 00000000..c0a0b1e8 --- /dev/null +++ b/internal/embed/skills/obol-dvt/references/api-examples.md @@ -0,0 +1,278 @@ +# Obol API — Example Calls & Responses + +Illustrative examples of API parameters and the key fields returned. +Use these to interpret real API responses and explain them to users. + +--- + +## Cluster Lock — `GET /v1/lock/{lockHash}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/lock/0x4d6e7f8a9b..." +``` + +**Response shape (key fields):** +```json +{ + "lock_hash": "0x4d6e7f8a9b...", + "config_hash": "0x1a2b3c4d5e...", + "name": "my-dv-cluster", + "network": "mainnet", + "threshold": 3, + "num_validators": 4, + "operators": [ + { "address": "0xAbCd...1234", "enr": "enr:-...", "approved": true } + ], + "validators": [ + { "public_key": "0xb3a2c1...", "fee_recipient_address": "0xDead...Beef" } + ], + "created_at": "2024-03-15T10:22:00Z" +} +``` + +**What to tell the user:** "Your cluster has 4 operators with a 3-of-4 threshold running 4 +validators on mainnet. All operators have approved the configuration." + +--- + +## Effectiveness — `GET /v1/effectiveness/{lockHash}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/effectiveness/0x4d6e7f8a9b..." +``` + +**Response shape:** +```json +{ + "effectiveness": [ + { + "public_key": "0xb3a2c1...", + "effectiveness": 0.987, + "attestation_effectiveness": 0.991, + "proposal_effectiveness": 1.0 + }, + { + "public_key": "0xc4d3e2...", + "effectiveness": 0.612, + "attestation_effectiveness": 0.608, + "proposal_effectiveness": null + } + ] +} +``` + +**Notes:** +- Scores are 0–1; anything above ~0.95 is healthy. +- `proposal_effectiveness: null` means no proposals have occurred yet — not a problem. +- Low `attestation_effectiveness` (e.g. 0.6) usually means one or more operators are offline + or have connectivity issues with the rest of the cluster. + +--- + +## Validator States — `GET /v1/state/{lockHash}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/state/0x4d6e7f8a9b..." +``` + +**Response shape:** +```json +{ + "validators": [ + { + "public_key": "0xb3a2c1...", + "index": 412503, + "status": "active_ongoing", + "balance": "32045231042" + }, + { + "public_key": "0xc4d3e2...", + "index": 412504, + "status": "active_exiting", + "balance": "32001000000" + } + ] +} +``` + +**Notes:** +- `balance` is in Gwei — divide by 1,000,000,000 for ETH (e.g. 32045231042 = 32.045 ETH). +- `active_exiting` means an exit has been initiated; pair with exit status summary to see signing progress. + +--- + +## Exit Status Summary — `GET /v1/exp/exit/status/summary/{lockHash}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x4d6e7f8a9b..." +``` + +**Response shape:** +```json +{ + "total_validators": 4, + "validators_ready_to_exit": 1, + "operators": [ + { "address": "0xAbCd...1234", "signed_exits": 3 }, + { "address": "0xEfGh...5678", "signed_exits": 3 }, + { "address": "0xIjKl...9012", "signed_exits": 2 }, + { "address": "0xMnOp...3456", "signed_exits": 1 } + ] +} +``` + +**What to tell the user:** "1 of 4 validators has reached the 3-of-4 threshold and is ready +to exit. Operators `0xIjKl...` and `0xMnOp...` still need to sign exits for the remaining +validators." + +--- + +## Detailed Exit Status — `GET /v1/exp/exit/status/{lockHash}` + +**curl (filtered by validator):** +```bash +curl -s "https://api.obol.tech/v1/exp/exit/status/0x4d6e7f8a9b...?validatorPubkey=0xc4d3e2...&page=1&limit=10" +``` + +**Note:** This endpoint uses 1-indexed pagination (start at `page=1`, not `page=0`). + +--- + +## Cluster Definition (pre-DKG) — `GET /v1/definition/{configHash}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/definition/0x1a2b3c4d5e..." +``` + +**Response shape:** +```json +{ + "config_hash": "0x1a2b3c4d5e...", + "name": "my-dv-cluster", + "network": "mainnet", + "threshold": 3, + "num_validators": 4, + "operators": [ + { "address": "0xAbCd...1234", "approved": false }, + { "address": "0xEfGh...5678", "approved": true } + ], + "created_at": "2024-03-14T08:00:00Z" +} +``` + +**What to tell the user:** "DKG hasn't happened yet — operator `0xAbCd...` still needs to +approve the definition before the ceremony can begin." + +--- + +## Operator Techne — `GET /v1/address/techne/{address}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/address/techne/0xAbCd...1234" +``` + +**Response shape:** +```json +{ + "address": "0xAbCd...1234", + "credential_level": "silver", + "issued_at": "2024-01-20T09:00:00Z" +} +``` + +Levels: `base` < `bronze` < `silver`. Silver = sustained high-quality mainnet operation. + +--- + +## Operator Badges — `GET /v1/address/badges/{address}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/address/badges/0xAbCd...1234" +``` + +**Response shape:** +```json +{ + "address": "0xAbCd...1234", + "badges": [ + { "type": "lido" }, + { "type": "etherfi" } + ] +} +``` + +Badges indicate protocol participation (Lido CSM, EtherFi, etc.). + +--- + +## Migrateable Validators — `GET /v1/address/migrateable-validators/{network}/{withdrawalAddress}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/address/migrateable-validators/mainnet/0xDead...Beef?limit=10&offset=0" +``` + +**Response shape:** +```json +{ + "validators": [ + { + "public_key": "0xaabbcc...", + "index": 300001, + "status": "active_ongoing", + "balance": "32100000000" + } + ], + "total": 3 +} +``` + +**What to tell the user:** "You have 3 active validators eligible for DVT migration. The +process: create a cluster definition with a matching withdrawal address, complete DKG, activate +the DVT cluster, then exit the solo validator." + +--- + +## Network Summary — `GET /v1/lock/network/summary/{network}` + +**curl:** +```bash +curl -s "https://api.obol.tech/v1/lock/network/summary/mainnet" +``` + +**Response shape:** +```json +{ + "network": "mainnet", + "total_clusters": 312, + "total_validators": 1248, + "avg_effectiveness": 0.971, + "total_operators": 89 +} +``` + +--- + +## Example Conversations + +**"My cluster 0x4d6e... has terrible performance, what's wrong?"** +1. Call `GET /v1/effectiveness/0x4d6e...` — check which validators underperform +2. Call `GET /v1/state/0x4d6e...` — check for non-`active_ongoing` statuses +3. Call `GET /v1/lock/0x4d6e...` — cross-reference operator list to identify likely offline operators + +**"How do I exit validator 0xb3a2c1...?"** +Exits are initiated in the Charon/validator client (write operation, not available here). +Use `GET /v1/exp/exit/status/0x...?validatorPubkey=0xb3a2c1...` to show current signing progress. + +**"Is 0xAbCd... a trustworthy operator?"** +1. `GET /v1/address/techne/0xAbCd...` — credential level +2. `GET /v1/address/badges/0xAbCd...` — protocol affiliations +3. `GET /v1/lock/operator/0xAbCd...` — how many clusters they run +4. `GET /v1/termsAndConditions/0xAbCd...` — T&C compliance diff --git a/internal/embed/skills/obol-k8s/SKILL.md b/internal/embed/skills/obol-k8s/SKILL.md new file mode 100644 index 00000000..5ec94ebf --- /dev/null +++ b/internal/embed/skills/obol-k8s/SKILL.md @@ -0,0 +1,148 @@ +--- +name: obol-k8s +description: "Kubernetes cluster awareness via ServiceAccount API. Use when: checking pod status, reading logs, listing services, viewing events, diagnosing deployment issues, or inspecting resource health in own namespace. NOT for: cross-namespace operations, creating/modifying resources, network management (use obol-network), or DVT operations (use obol-dvt)." +metadata: { "openclaw": { "emoji": "☸️", "requires": { "bins": ["curl", "python3"] } } } +--- + +# Obol K8s + +Monitor your Kubernetes environment using the mounted ServiceAccount token. Read-only access to pods, logs, services, events, and more within your own namespace. + +## When to Use + +- "What pods are running?" +- "Show me the logs for the openclaw pod" +- "Are there any warning events?" +- "What services are available?" +- "Why is a pod crashing?" +- "How many replicas are ready?" +- Diagnosing deployment issues (restarts, OOMKill, image pull errors) + +## When NOT to Use + +- Cross-namespace operations — SA is scoped to own namespace only +- Creating or modifying resources — read-only access +- Network deployment management — use the obol CLI +- Blockchain queries — use `obol-blockchain` +- DVT cluster monitoring — use `obol-dvt` + +## Scope + +**Read-only access to own namespace only.** The ServiceAccount has `get`, `list`, `watch` permissions on: + +| Resource | API Group | +|----------|-----------| +| Pods | core | +| Pods/log | core | +| Services | core | +| ConfigMaps | core | +| Events | core | +| PersistentVolumeClaims | core | +| Deployments | apps | +| ReplicaSets | apps | +| StatefulSets | apps | +| Jobs | batch | +| CronJobs | batch | + +**Cannot:** list namespaces, read other namespaces, create/update/delete resources. + +## Quick Start + +```bash +# List all pods with status +python3 scripts/kube.py pods + +# Get logs from a pod +python3 scripts/kube.py logs openclaw-7f8b9c6d5-x2k4j + +# Recent warning events +python3 scripts/kube.py events --type Warning + +# List services +python3 scripts/kube.py services + +# Deployment status +python3 scripts/kube.py deployments + +# Full details of a resource +python3 scripts/kube.py describe pod openclaw-7f8b9c6d5-x2k4j +``` + +## Direct curl + +The SA token and CA cert are mounted in the pod. You can query the Kubernetes API directly: + +```bash +# Setup variables +TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) +NS=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) +API="https://kubernetes.default.svc" +CA="--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + +# List pods +curl -s $CA -H "Authorization: Bearer $TOKEN" \ + "$API/api/v1/namespaces/$NS/pods" | python3 -c " +import sys,json +pods = json.load(sys.stdin)['items'] +for p in pods: + name = p['metadata']['name'] + phase = p['status']['phase'] + restarts = sum(c.get('restartCount',0) for c in p['status'].get('containerStatuses',[])) + print(f'{name} {phase} restarts={restarts}') +" + +# Get pod logs (last 50 lines) +curl -s $CA -H "Authorization: Bearer $TOKEN" \ + "$API/api/v1/namespaces/$NS/pods//log?tailLines=50" + +# List events (warnings only) +curl -s $CA -H "Authorization: Bearer $TOKEN" \ + "$API/api/v1/namespaces/$NS/events?fieldSelector=type=Warning" +``` + +## Interpreting Pod Status + +| Phase | Meaning | +|-------|---------| +| `Running` | Pod is executing normally | +| `Pending` | Waiting to be scheduled (check events for reason) | +| `Succeeded` | All containers exited successfully (common for Jobs) | +| `Failed` | All containers terminated, at least one failed | +| `Unknown` | Pod state cannot be determined | + +### Container States + +| State | Common Cause | +|-------|-------------| +| `Waiting: CrashLoopBackOff` | Container crashes repeatedly. Check logs. | +| `Waiting: ImagePullBackOff` | Cannot pull container image. Check image name/tag. | +| `Waiting: ContainerCreating` | Pulling image or mounting volumes. | +| `Terminated: OOMKilled` | Out of memory. Pod needs higher memory limits. | +| `Terminated: Error` | Container exited with non-zero code. Check logs. | + +## Troubleshooting Patterns + +### Pod Won't Start + +1. `python3 scripts/kube.py pods` — check status +2. `python3 scripts/kube.py events --type Warning` — look for scheduling or image errors +3. `python3 scripts/kube.py describe pod ` — check conditions and container state + +### Pod Keeps Restarting + +1. `python3 scripts/kube.py pods` — check restart count +2. `python3 scripts/kube.py logs ` — check last log output before crash +3. Look for OOMKilled in container status — if so, memory limit too low + +### Service Not Reachable + +1. `python3 scripts/kube.py services` — verify service exists and ports +2. `python3 scripts/kube.py pods` — verify backing pods are Running +3. `python3 scripts/kube.py describe service ` — check endpoints + +## Constraints + +- **Read-only** — cannot create, modify, or delete any resources +- **Own namespace only** — cannot see other namespaces or cluster-level resources +- **No kubectl** — uses curl + SA token (kubectl binary not installed in pod) +- **Formatted output** — the helper script outputs human-readable text, not raw JSON diff --git a/internal/embed/skills/obol-k8s/scripts/kube.py b/internal/embed/skills/obol-k8s/scripts/kube.py new file mode 100644 index 00000000..08139d21 --- /dev/null +++ b/internal/embed/skills/obol-k8s/scripts/kube.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Kubernetes API helper for Obol Stack OpenClaw pods. + +Uses the mounted ServiceAccount token to query the Kubernetes API. +No kubectl required — pure HTTP via urllib. + +Usage: + python3 kube.py [args] + +Commands: + pods List pods with status + logs [--tail N] Get pod logs + events [--type Warning] List events + services List services + deployments List deployments + configmaps List configmaps + describe Get full resource detail +""" + +import json +import os +import ssl +import sys +import urllib.request +from datetime import datetime, timezone + +# ServiceAccount paths +SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount" +TOKEN_PATH = os.path.join(SA_DIR, "token") +NS_PATH = os.path.join(SA_DIR, "namespace") +CA_PATH = os.path.join(SA_DIR, "ca.crt") +API_SERVER = "https://kubernetes.default.svc" + + +def load_sa(): + """Load ServiceAccount token and namespace.""" + try: + with open(TOKEN_PATH) as f: + token = f.read().strip() + with open(NS_PATH) as f: + namespace = f.read().strip() + return token, namespace + except FileNotFoundError: + print("Error: ServiceAccount not mounted. Are you running inside a Kubernetes pod?", file=sys.stderr) + sys.exit(1) + + +def make_ssl_context(): + """Create SSL context with the cluster CA.""" + ctx = ssl.create_default_context() + if os.path.exists(CA_PATH): + ctx.load_verify_locations(CA_PATH) + return ctx + + +def api_get(path, token, ssl_ctx): + """GET request to the Kubernetes API.""" + url = f"{API_SERVER}{path}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + try: + with urllib.request.urlopen(req, context=ssl_ctx, timeout=15) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else "" + print(f"API error {e.code}: {body[:200]}", file=sys.stderr) + sys.exit(1) + + +def age(timestamp_str): + """Convert ISO timestamp to human-readable age.""" + if not timestamp_str: + return "?" + try: + ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) + delta = datetime.now(timezone.utc) - ts + secs = int(delta.total_seconds()) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + if secs < 86400: + return f"{secs // 3600}h" + return f"{secs // 86400}d" + except (ValueError, TypeError): + return "?" + + +def cmd_pods(ns, token, ssl_ctx): + """List pods with status, restarts, and age.""" + data = api_get(f"/api/v1/namespaces/{ns}/pods", token, ssl_ctx) + items = data.get("items", []) + if not items: + print("No pods found.") + return + + print(f"{'NAME':<50} {'STATUS':<20} {'RESTARTS':<10} {'AGE':<8}") + print("-" * 90) + for pod in items: + name = pod["metadata"]["name"] + phase = pod["status"].get("phase", "Unknown") + created = pod["metadata"].get("creationTimestamp", "") + + restarts = 0 + container_statuses = pod["status"].get("containerStatuses", []) + for cs in container_statuses: + restarts += cs.get("restartCount", 0) + # Show waiting reason if not Running + state = cs.get("state", {}) + if "waiting" in state: + reason = state["waiting"].get("reason", "") + if reason: + phase = reason + + print(f"{name:<50} {phase:<20} {restarts:<10} {age(created):<8}") + + +def cmd_logs(ns, token, ssl_ctx, pod_name, tail=100): + """Get pod logs.""" + path = f"/api/v1/namespaces/{ns}/pods/{pod_name}/log?tailLines={tail}" + url = f"{API_SERVER}{path}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + try: + with urllib.request.urlopen(req, context=make_ssl_context(), timeout=30) as resp: + print(resp.read().decode(errors="replace")) + except urllib.error.HTTPError as e: + print(f"Error getting logs: {e.code}", file=sys.stderr) + sys.exit(1) + + +def cmd_events(ns, token, ssl_ctx, event_type=None): + """List events, optionally filtered by type.""" + path = f"/api/v1/namespaces/{ns}/events" + if event_type: + path += f"?fieldSelector=type={event_type}" + + data = api_get(path, token, ssl_ctx) + items = data.get("items", []) + if not items: + print("No events found.") + return + + # Sort by last timestamp + items.sort(key=lambda e: e.get("lastTimestamp", "") or e.get("metadata", {}).get("creationTimestamp", "")) + + print(f"{'AGE':<8} {'TYPE':<10} {'REASON':<25} {'OBJECT':<35} {'MESSAGE'}") + print("-" * 120) + for ev in items[-30:]: # Last 30 events + ts = ev.get("lastTimestamp") or ev.get("metadata", {}).get("creationTimestamp", "") + etype = ev.get("type", "?") + reason = ev.get("reason", "?") + obj_ref = ev.get("involvedObject", {}) + obj = f"{obj_ref.get('kind', '?')}/{obj_ref.get('name', '?')}" + msg = ev.get("message", "")[:80] + print(f"{age(ts):<8} {etype:<10} {reason:<25} {obj:<35} {msg}") + + +def cmd_services(ns, token, ssl_ctx): + """List services with type and ports.""" + data = api_get(f"/api/v1/namespaces/{ns}/services", token, ssl_ctx) + items = data.get("items", []) + if not items: + print("No services found.") + return + + print(f"{'NAME':<40} {'TYPE':<15} {'CLUSTER-IP':<18} {'PORTS'}") + print("-" * 100) + for svc in items: + name = svc["metadata"]["name"] + stype = svc["spec"].get("type", "ClusterIP") + cluster_ip = svc["spec"].get("clusterIP", "None") + ports = [] + for p in svc["spec"].get("ports", []): + port_str = f"{p.get('port', '?')}/{p.get('protocol', 'TCP')}" + if p.get("targetPort"): + port_str += f"->{p['targetPort']}" + ports.append(port_str) + print(f"{name:<40} {stype:<15} {cluster_ip:<18} {', '.join(ports)}") + + +def cmd_deployments(ns, token, ssl_ctx): + """List deployments with ready/desired counts.""" + data = api_get(f"/apis/apps/v1/namespaces/{ns}/deployments", token, ssl_ctx) + items = data.get("items", []) + if not items: + print("No deployments found.") + return + + print(f"{'NAME':<40} {'READY':<12} {'UP-TO-DATE':<12} {'AVAILABLE':<12} {'AGE':<8}") + print("-" * 90) + for dep in items: + name = dep["metadata"]["name"] + created = dep["metadata"].get("creationTimestamp", "") + status = dep.get("status", {}) + desired = dep["spec"].get("replicas", 0) + ready = status.get("readyReplicas", 0) + updated = status.get("updatedReplicas", 0) + available = status.get("availableReplicas", 0) + print(f"{name:<40} {ready}/{desired:<10} {updated:<12} {available:<12} {age(created):<8}") + + +def cmd_configmaps(ns, token, ssl_ctx): + """List configmaps.""" + data = api_get(f"/api/v1/namespaces/{ns}/configmaps", token, ssl_ctx) + items = data.get("items", []) + if not items: + print("No configmaps found.") + return + + print(f"{'NAME':<50} {'DATA KEYS':<6} {'AGE':<8}") + print("-" * 70) + for cm in items: + name = cm["metadata"]["name"] + created = cm["metadata"].get("creationTimestamp", "") + data_keys = len(cm.get("data", {})) + print(f"{name:<50} {data_keys:<6} {age(created):<8}") + + +def cmd_describe(ns, token, ssl_ctx, resource_type, name): + """Get full resource detail as JSON.""" + type_map = { + "pod": f"/api/v1/namespaces/{ns}/pods/{name}", + "service": f"/api/v1/namespaces/{ns}/services/{name}", + "deployment": f"/apis/apps/v1/namespaces/{ns}/deployments/{name}", + "configmap": f"/api/v1/namespaces/{ns}/configmaps/{name}", + "event": f"/api/v1/namespaces/{ns}/events/{name}", + "pvc": f"/api/v1/namespaces/{ns}/persistentvolumeclaims/{name}", + "statefulset": f"/apis/apps/v1/namespaces/{ns}/statefulsets/{name}", + "job": f"/apis/batch/v1/namespaces/{ns}/jobs/{name}", + "cronjob": f"/apis/batch/v1/namespaces/{ns}/cronjobs/{name}", + "replicaset": f"/apis/apps/v1/namespaces/{ns}/replicasets/{name}", + } + + path = type_map.get(resource_type) + if not path: + print(f"Unknown resource type: {resource_type}", file=sys.stderr) + print(f"Supported: {', '.join(sorted(type_map.keys()))}", file=sys.stderr) + sys.exit(1) + + data = api_get(path, token, ssl_ctx) + print(json.dumps(data, indent=2)) + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 kube.py [args]") + print("\nCommands:") + print(" pods List pods with status") + print(" logs [--tail N] Get pod logs (default 100 lines)") + print(" events [--type Warning] List events") + print(" services List services") + print(" deployments List deployments") + print(" configmaps List configmaps") + print(" describe Get full resource detail") + sys.exit(1) + + token, ns = load_sa() + ssl_ctx = make_ssl_context() + cmd = sys.argv[1] + + if cmd == "pods": + cmd_pods(ns, token, ssl_ctx) + elif cmd == "logs": + if len(sys.argv) < 3: + print("Usage: python3 kube.py logs [--tail N]", file=sys.stderr) + sys.exit(1) + pod_name = sys.argv[2] + tail = 100 + if "--tail" in sys.argv: + idx = sys.argv.index("--tail") + if idx + 1 < len(sys.argv): + tail = int(sys.argv[idx + 1]) + cmd_logs(ns, token, ssl_ctx, pod_name, tail) + elif cmd == "events": + event_type = None + if "--type" in sys.argv: + idx = sys.argv.index("--type") + if idx + 1 < len(sys.argv): + event_type = sys.argv[idx + 1] + cmd_events(ns, token, ssl_ctx, event_type) + elif cmd == "services": + cmd_services(ns, token, ssl_ctx) + elif cmd == "deployments": + cmd_deployments(ns, token, ssl_ctx) + elif cmd == "configmaps": + cmd_configmaps(ns, token, ssl_ctx) + elif cmd == "describe": + if len(sys.argv) < 4: + print("Usage: python3 kube.py describe ", file=sys.stderr) + sys.exit(1) + cmd_describe(ns, token, ssl_ctx, sys.argv[2], sys.argv[3]) + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index cd723874..8be7739d 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -312,22 +312,22 @@ func portForward(t *testing.T, cfg *config.Config, namespace string) string { return "" } -// chatCompletion sends a chat completion request with the gateway Bearer token -// and returns the assistant response. -func chatCompletion(t *testing.T, baseURL, modelName, token string) string { +// chatCompletionWithPrompt sends a chat completion with a custom user message. +func chatCompletionWithPrompt(t *testing.T, baseURL, modelName, token, prompt string, maxTokens int) string { t.Helper() - body := fmt.Sprintf(`{ - "model": "%s", - "messages": [{"role":"user","content":"Reply with exactly one word: hello"}], - "max_tokens": 32 - }`, modelName) + reqBody := map[string]interface{}{ + "model": modelName, + "messages": []map[string]string{{"role": "user", "content": prompt}}, + "max_tokens": maxTokens, + } + bodyBytes, _ := json.Marshal(reqBody) - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/chat/completions", - strings.NewReader(body), + bytes.NewReader(bodyBytes), ) if err != nil { t.Fatalf("failed to create request: %v", err) @@ -364,6 +364,13 @@ func chatCompletion(t *testing.T, baseURL, modelName, token string) string { return result.Choices[0].Message.Content } +// chatCompletion sends a chat completion request with the gateway Bearer token +// and returns the assistant response. +func chatCompletion(t *testing.T, baseURL, modelName, token string) string { + t.Helper() + return chatCompletionWithPrompt(t, baseURL, modelName, token, "Reply with exactly one word: hello", 32) +} + // cleanupInstance deletes an OpenClaw instance via `obol openclaw delete --force`. func cleanupInstance(t *testing.T, cfg *config.Config, id string) { t.Helper() @@ -529,3 +536,336 @@ func TestIntegration_MultiInstance(t *testing.T) { t.Logf("instance %s replied: %s", id, reply) } } + +// --------------------------------------------------------------------------- +// Skills integration tests +// --------------------------------------------------------------------------- + +// TestIntegration_SkillsStagedOnSync verifies that `obol openclaw sync` +// stages embedded skills into the deployment directory and injects them +// into the PVC volume path on the host filesystem. +func TestIntegration_SkillsStagedOnSync(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skills-stage" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q", id) + scaffoldInstance(t, cfg, id, models) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + // 1. Verify skills were staged in the deployment directory + deployDir := deploymentPath(cfg, id) + skillsDir := filepath.Join(deployDir, "skills") + expectedSkills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + + for _, skill := range expectedSkills { + skillMD := filepath.Join(skillsDir, skill, "SKILL.md") + info, err := os.Stat(skillMD) + if err != nil { + t.Errorf("skill %q not staged in deployment dir: %v", skill, err) + continue + } + if info.Size() == 0 { + t.Errorf("skill %q SKILL.md is empty", skill) + } + t.Logf(" staged: %s/SKILL.md (%d bytes)", skill, info.Size()) + } + + // Verify scripts and references were also staged + for _, sub := range []string{ + "obol-blockchain/scripts/rpc.py", + "obol-k8s/scripts/kube.py", + "obol-dvt/references/api-examples.md", + } { + if _, err := os.Stat(filepath.Join(skillsDir, sub)); err != nil { + t.Errorf("missing staged file %s: %v", sub, err) + } + } + + // 2. Verify skills were injected into the PVC volume path + volumePath := skillsVolumePath(cfg, id) + for _, skill := range expectedSkills { + skillMD := filepath.Join(volumePath, skill, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Errorf("skill %q not injected to volume: %v", skill, err) + } else { + t.Logf(" injected: %s/SKILL.md in volume", skill) + } + } +} + +// TestIntegration_SkillsVisibleInPod verifies that after deployment, skills +// are visible inside the running OpenClaw pod at /data/.openclaw/skills/. +func TestIntegration_SkillsVisibleInPod(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skills-pod" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q", id) + scaffoldInstance(t, cfg, id, models) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + waitForPodReady(t, cfg, namespace) + + // List skills inside the pod via kubectl exec (without -it for non-interactive) + output := obolRun(t, cfg, "kubectl", + "exec", "-c", "openclaw", + "-n", namespace, "deploy/openclaw", "--", + "ls", "/data/.openclaw/skills/", + ) + t.Logf("skills visible in pod:\n%s", output) + + expectedSkills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + for _, skill := range expectedSkills { + if !strings.Contains(output, skill) { + t.Errorf("skill %q not visible in pod; ls output:\n%s", skill, output) + } + } + + // Verify SKILL.md content is readable inside the pod for a representative skill + mdContent := obolRun(t, cfg, "kubectl", + "exec", "-c", "openclaw", + "-n", namespace, "deploy/openclaw", "--", + "head", "-5", "/data/.openclaw/skills/obol-blockchain/SKILL.md", + ) + if !strings.Contains(mdContent, "obol-blockchain") && !strings.Contains(mdContent, "blockchain") { + t.Errorf("obol-blockchain SKILL.md not readable in pod; got:\n%s", mdContent) + } + t.Logf("obol-blockchain SKILL.md header in pod:\n%s", mdContent) +} + +// TestIntegration_SkillsSync verifies that `obol openclaw skills sync --from` +// copies a local skills directory to the PVC volume path. +func TestIntegration_SkillsSync(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skills-sync" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q", id) + scaffoldInstance(t, cfg, id, models) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + waitForPodReady(t, cfg, namespace) + + // Create a custom skill in a temporary directory + customSkillsDir := t.TempDir() + customSkillDir := filepath.Join(customSkillsDir, "test-custom") + if err := os.MkdirAll(customSkillDir, 0755); err != nil { + t.Fatalf("failed to create custom skill dir: %v", err) + } + customMD := "---\nmetadata: {}\n---\n# Test Custom Skill\nThis is a test skill for integration testing.\n" + if err := os.WriteFile(filepath.Join(customSkillDir, "SKILL.md"), []byte(customMD), 0644); err != nil { + t.Fatalf("failed to write custom SKILL.md: %v", err) + } + + // Sync custom skills via obol openclaw skills sync + t.Log("syncing custom skills via: obol openclaw skills sync --from " + customSkillsDir) + obolRun(t, cfg, "openclaw", "skills", "sync", "--from", customSkillsDir) + + // Verify custom skill landed in the volume path + volumePath := skillsVolumePath(cfg, id) + customMDPath := filepath.Join(volumePath, "test-custom", "SKILL.md") + data, err := os.ReadFile(customMDPath) + if err != nil { + t.Fatalf("custom skill not found in volume path: %v", err) + } + if !strings.Contains(string(data), "Test Custom Skill") { + t.Errorf("custom SKILL.md content mismatch; got:\n%s", string(data)) + } + t.Logf("custom skill synced to volume: %s", customMDPath) + + // Verify custom skill is visible inside the pod + output := obolRun(t, cfg, "kubectl", + "exec", "-c", "openclaw", + "-n", namespace, "deploy/openclaw", "--", + "ls", "/data/.openclaw/skills/", + ) + if !strings.Contains(output, "test-custom") { + t.Errorf("custom skill not visible in pod after sync; ls output:\n%s", output) + } + t.Logf("skills in pod after sync:\n%s", output) +} + +// TestIntegration_SkillsIdempotentSync verifies that re-running sync does not +// overwrite user-customised skills in the deployment directory. +func TestIntegration_SkillsIdempotentSync(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skills-idem" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q", id) + scaffoldInstance(t, cfg, id, models) + + // First sync — stages and injects default skills + t.Log("first sync...") + obolRun(t, cfg, "openclaw", "sync", id) + + // Add a custom file to the staged skills directory (simulating user customisation) + 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) + } + if err := os.WriteFile(marker, []byte("# Custom User Skill"), 0644); err != nil { + t.Fatalf("write marker: %v", err) + } + + // Second sync — stageDefaultSkills should skip (skills/ dir already exists) + t.Log("second sync (idempotent)...") + obolRun(t, cfg, "openclaw", "sync", id) + + // Custom marker should still be present + if _, err := os.Stat(marker); err != nil { + t.Errorf("user-customised skill removed after re-sync: %v", err) + } + + // Embedded skills should also still be present (from first sync) + for _, skill := range []string{"hello", "obol-blockchain"} { + skillMD := filepath.Join(deployDir, "skills", skill, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Errorf("embedded skill %q removed after re-sync: %v", skill, err) + } + } + + // Custom skill should also be injected to volume (injectSkillsToVolume always runs) + volumePath := skillsVolumePath(cfg, id) + if _, err := os.Stat(filepath.Join(volumePath, "custom-user-skill", "SKILL.md")); err != nil { + t.Errorf("custom skill not injected to volume on re-sync: %v", err) + } +} + +// TestIntegration_SkillInference verifies that OpenClaw loads skills into the +// agent's context and uses them during inference. Deploys an instance, sends a +// prompt asking about available skills, and checks the response references our +// embedded skill names — proving skills flow from embed → staging → volume → +// pod file watcher → agent system prompt → inference response. +func TestIntegration_SkillInference(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skill-infer" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q with Ollama models: %v", id, models) + scaffoldInstance(t, cfg, id, models) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + waitForPodReady(t, cfg, namespace) + + token := getGatewayToken(t, cfg, id) + baseURL := portForward(t, cfg, namespace) + agentModel := fmt.Sprintf("ollama/%s", models[0]) + + // Ask the agent to list its skills. OpenClaw injects SKILL.md descriptions + // into the system prompt, so the agent should know about them. + prompt := "List every skill you have access to. For each skill, state its exact name. Be concise — just the names, one per line." + t.Logf("sending skill-awareness prompt to %s", agentModel) + reply := chatCompletionWithPrompt(t, baseURL, agentModel, token, prompt, 256) + t.Logf("agent reply:\n%s", reply) + + replyLower := strings.ToLower(reply) + + // The agent must mention at least 2 of our 4 embedded skills. + // We check for partial matches to be resilient to model output variations + // (e.g., "obol-blockchain" vs "obol blockchain" vs "Obol Blockchain"). + skillHits := 0 + skillChecks := []struct { + name string + patterns []string + }{ + {"hello", []string{"hello"}}, + {"obol-blockchain", []string{"blockchain", "obol-blockchain"}}, + {"obol-k8s", []string{"obol-k8s", "kubernetes", "k8s"}}, + {"obol-dvt", []string{"obol-dvt", "dvt", "distributed validator"}}, + } + + for _, sc := range skillChecks { + for _, pattern := range sc.patterns { + if strings.Contains(replyLower, pattern) { + t.Logf(" ✓ agent referenced skill: %s (matched %q)", sc.name, pattern) + skillHits++ + break + } + } + } + + if skillHits < 2 { + t.Errorf("agent only referenced %d/4 skills — skills may not be loaded into context.\nFull reply:\n%s", skillHits, reply) + } +} + +// TestIntegration_SkillsSmokeTest runs the Python smoke tests inside the pod +// to verify that skill scripts (rpc.py, kube.py) actually work against live +// services (eRPC, Kubernetes API, Obol API). +func TestIntegration_SkillsSmokeTest(t *testing.T) { + cfg := requireCluster(t) + models := requireOllama(t) + + const id = "test-skills-smoke" + t.Cleanup(func() { cleanupInstance(t, cfg, id) }) + + t.Logf("scaffolding OpenClaw instance %q", id) + scaffoldInstance(t, cfg, id, models) + + t.Log("deploying via: obol openclaw sync " + id) + obolRun(t, cfg, "openclaw", "sync", id) + + namespace := fmt.Sprintf("%s-%s", appName, id) + waitForPodReady(t, cfg, namespace) + + // Find the smoke test script relative to the module root + moduleRoot := findModuleRoot() + if moduleRoot == "" { + t.Fatal("could not find module root") + } + smokeScript := filepath.Join(moduleRoot, "tests", "skills_smoke_test.py") + scriptData, err := os.ReadFile(smokeScript) + if err != nil { + t.Fatalf("failed to read smoke test script: %v", err) + } + + // Pipe the smoke test into the pod via kubectl exec + t.Log("running skills smoke tests inside pod...") + obolBinary := filepath.Join(cfg.BinDir, "obol") + cmd := exec.Command(obolBinary, "kubectl", + "exec", "-i", "-c", "openclaw", + "-n", namespace, "deploy/openclaw", "--", + "python3", "-", + ) + cmd.Stdin = strings.NewReader(string(scriptData)) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("smoke tests failed: %v\nstdout:\n%s\nstderr:\n%s", + err, stdout.String(), stderr.String()) + } + + output := stdout.String() + t.Logf("smoke test output:\n%s", output) + + // Verify all tests passed + if !strings.Contains(output, "0 failed") { + t.Errorf("some smoke tests failed:\n%s", output) + } +} diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go new file mode 100644 index 00000000..28f2378e --- /dev/null +++ b/internal/openclaw/skills_injection_test.go @@ -0,0 +1,112 @@ +package openclaw + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/config" +) + +func TestSkillsVolumePath(t *testing.T) { + cfg := &config.Config{DataDir: "/data/obol"} + got := skillsVolumePath(cfg, "default") + want := filepath.Join("/data/obol", "openclaw-default", "openclaw-data", ".openclaw", "skills") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestStageDefaultSkills(t *testing.T) { + deploymentDir := t.TempDir() + + // stageDefaultSkills should create skills/ and populate it + stageDefaultSkills(deploymentDir) + + skillsDir := filepath.Join(deploymentDir, "skills") + if _, err := os.Stat(skillsDir); err != nil { + t.Fatalf("skills dir not created: %v", err) + } + + // Verify all expected skills were staged + for _, skill := range []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} { + skillMD := filepath.Join(skillsDir, skill, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Errorf("%s/SKILL.md not staged: %v", skill, err) + } + } +} + +func TestStageDefaultSkillsSkipsExisting(t *testing.T) { + deploymentDir := t.TempDir() + + // Pre-create skills directory with custom content + skillsDir := filepath.Join(deploymentDir, "skills") + if err := os.MkdirAll(skillsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + marker := filepath.Join(skillsDir, "custom-marker.txt") + if err := os.WriteFile(marker, []byte("keep"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // stageDefaultSkills should skip because skills/ already exists + stageDefaultSkills(deploymentDir) + + // Marker file should still be there + if _, err := os.Stat(marker); err != nil { + t.Errorf("custom marker removed: %v", err) + } + + // And no embedded skills should have been written (directory was pre-existing) + if _, err := os.Stat(filepath.Join(skillsDir, "hello", "SKILL.md")); err == nil { + t.Errorf("embedded skills should NOT have been staged into existing directory") + } +} + +func TestInjectSkillsToVolume(t *testing.T) { + deploymentDir := t.TempDir() + dataDir := t.TempDir() + cfg := &config.Config{DataDir: dataDir} + + // Stage skills first + stageDefaultSkills(deploymentDir) + + // Inject to volume + injectSkillsToVolume(cfg, "test-inject", deploymentDir) + + // Verify skills landed in the volume path + volumePath := skillsVolumePath(cfg, "test-inject") + for _, skill := range []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} { + skillMD := filepath.Join(volumePath, skill, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Errorf("%s/SKILL.md not injected to volume: %v", skill, err) + } + } + + // Verify scripts and references are also injected + for _, sub := range []string{ + "obol-blockchain/scripts/rpc.py", + "obol-k8s/scripts/kube.py", + "obol-dvt/references/api-examples.md", + } { + path := filepath.Join(volumePath, sub) + if _, err := os.Stat(path); err != nil { + t.Errorf("missing %s in volume: %v", sub, err) + } + } +} + +func TestInjectSkillsNoopWithoutSkillsDir(t *testing.T) { + deploymentDir := t.TempDir() + dataDir := t.TempDir() + cfg := &config.Config{DataDir: dataDir} + + // Don't stage anything — inject should be a no-op + injectSkillsToVolume(cfg, "empty", deploymentDir) + + volumePath := skillsVolumePath(cfg, "empty") + if _, err := os.Stat(volumePath); err == nil { + t.Errorf("volume path should not exist when no skills staged") + } +} diff --git a/tests/skills_smoke_test.py b/tests/skills_smoke_test.py new file mode 100644 index 00000000..05a7224f --- /dev/null +++ b/tests/skills_smoke_test.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Smoke tests for OpenClaw skills (obol-blockchain, obol-k8s, obol-dvt). + +Run inside the OpenClaw pod: + obol kubectl exec -i -n openclaw-default deploy/openclaw -c openclaw -- python3 - < tests/skills_smoke_test.py + +Or from outside: + obol kubectl exec -i -n openclaw- deploy/openclaw -c openclaw -- python3 - < tests/skills_smoke_test.py +""" + +import json +import os +import re +import subprocess +import sys +import urllib.request + +SKILLS_DIR = "/data/.openclaw/skills" +RPC = os.path.join(SKILLS_DIR, "obol-blockchain", "scripts", "rpc.py") +KUBE = os.path.join(SKILLS_DIR, "obol-k8s", "scripts", "kube.py") + +passed = 0 +failed = 0 +errors = [] + + +def test(name, fn): + global passed, failed + try: + fn() + passed += 1 + print(f" \033[32mPASS\033[0m {name}") + except AssertionError as e: + failed += 1 + errors.append((name, str(e))) + print(f" \033[31mFAIL\033[0m {name}: {e}") + except Exception as e: + failed += 1 + errors.append((name, f"unexpected: {e}")) + print(f" \033[31mFAIL\033[0m {name}: unexpected error: {e}") + + +def run(cmd, timeout=30): + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout.strip(), r.stderr.strip() + + +def http_get(url, timeout=15): + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read()) + + +# ────────────────────────────────────────────── +# obol-blockchain tests +# ────────────────────────────────────────────── +print("\n\033[1m--- obol-blockchain ---\033[0m") + + +def test_blockchain_files(): + for f in [ + "SKILL.md", + "scripts/rpc.py", + "references/erc20-methods.md", + "references/common-contracts.md", + ]: + path = os.path.join(SKILLS_DIR, "obol-blockchain", f) + assert os.path.isfile(path), f"missing: {f}" + + +test("blockchain/files_exist", test_blockchain_files) + + +def test_block_number(): + rc, out, err = run(["python3", RPC, "eth_blockNumber"]) + assert rc == 0, f"exit {rc}: {err}" + assert "Block:" in out, f"unexpected output: {out}" + m = re.search(r"Block:\s+([\d,]+)", out) + assert m, f"no block number found in: {out}" + block = int(m.group(1).replace(",", "")) + assert block > 20_000_000, f"block number too low: {block}" + + +test("blockchain/block_number", test_block_number) + + +def test_chain_id(): + rc, out, err = run(["python3", RPC, "eth_chainId"]) + assert rc == 0, f"exit {rc}: {err}" + assert "Chain ID: 1" in out, f"unexpected chain id: {out}" + assert "mainnet" in out, f"missing 'mainnet' in: {out}" + + +test("blockchain/chain_id", test_chain_id) + + +def test_gas_price(): + rc, out, err = run(["python3", RPC, "eth_gasPrice"]) + assert rc == 0, f"exit {rc}: {err}" + assert "Gwei" in out, f"missing 'Gwei' in: {out}" + m = re.search(r"([\d.]+)\s*Gwei", out) + assert m, f"no gwei value in: {out}" + gwei = float(m.group(1)) + assert gwei > 0, f"gas price is 0" + + +test("blockchain/gas_price", test_gas_price) + + +def test_eth_balance(): + vitalik = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" + rc, out, err = run(["python3", RPC, "eth_getBalance", vitalik]) + assert rc == 0, f"exit {rc}: {err}" + assert "ETH" in out, f"missing 'ETH' in: {out}" + m = re.search(r"([\d.]+)\s*ETH", out) + assert m, f"no ETH value in: {out}" + eth = float(m.group(1)) + assert eth > 0, f"balance is 0" + + +test("blockchain/eth_balance", test_eth_balance) + + +def test_erc20_total_supply(): + usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + rc, out, err = run(["python3", RPC, "eth_call", usdc, "0x18160ddd"]) + assert rc == 0, f"exit {rc}: {err}" + assert "Result:" in out or "0x" in out, f"unexpected output: {out}" + + +test("blockchain/erc20_total_supply", test_erc20_total_supply) + + +def test_hoodi_chain_id(): + rc, out, err = run(["python3", RPC, "--network", "evm/560048", "eth_chainId"]) + assert rc == 0, f"exit {rc}: {err}" + assert "560048" in out, f"missing '560048' in: {out}" + assert "hoodi" in out, f"missing 'hoodi' in: {out}" + + +test("blockchain/hoodi_chain_id", test_hoodi_chain_id) + + +# ────────────────────────────────────────────── +# obol-k8s tests +# ────────────────────────────────────────────── +print("\n\033[1m--- obol-k8s ---\033[0m") + + +def test_k8s_files(): + for f in ["SKILL.md", "scripts/kube.py"]: + path = os.path.join(SKILLS_DIR, "obol-k8s", f) + assert os.path.isfile(path), f"missing: {f}" + + +test("k8s/files_exist", test_k8s_files) + +# We'll capture pod name from the pods test for use in logs test +_discovered_pod = [None] + + +def test_pods(): + rc, out, err = run(["python3", KUBE, "pods"]) + assert rc == 0, f"exit {rc}: {err}" + assert "openclaw" in out.lower(), f"no openclaw pod in: {out}" + # extract first pod name containing "openclaw" + for line in out.splitlines(): + if "openclaw" in line.lower() and not line.startswith(("-", "NAME", "=")): + _discovered_pod[0] = line.split()[0] + break + + +test("k8s/pods", test_pods) + + +def test_services(): + rc, out, err = run(["python3", KUBE, "services"]) + assert rc == 0, f"exit {rc}: {err}" + assert len(out) > 0, "empty output" + + +test("k8s/services", test_services) + + +def test_deployments(): + rc, out, err = run(["python3", KUBE, "deployments"]) + assert rc == 0, f"exit {rc}: {err}" + assert "openclaw" in out.lower(), f"no openclaw deployment in: {out}" + + +test("k8s/deployments", test_deployments) + + +def test_events(): + rc, out, err = run(["python3", KUBE, "events"]) + assert rc == 0, f"exit {rc}: {err}" + # events may legitimately be empty + + +test("k8s/events", test_events) + + +def test_configmaps(): + rc, out, err = run(["python3", KUBE, "configmaps"]) + assert rc == 0, f"exit {rc}: {err}" + assert "openclaw" in out.lower(), f"no openclaw configmap in: {out}" + + +test("k8s/configmaps", test_configmaps) + + +def test_logs(): + pod = _discovered_pod[0] + assert pod, "no pod discovered from pods test" + rc, out, err = run(["python3", KUBE, "logs", pod, "--tail", "10"]) + assert rc == 0, f"exit {rc}: {err}" + assert len(out) > 0, "empty logs" + + +test("k8s/logs", test_logs) + + +def test_describe_deployment(): + rc, out, err = run(["python3", KUBE, "describe", "deployment", "openclaw"]) + assert rc == 0, f"exit {rc}: {err}" + assert "replica" in out.lower() or "Replica" in out, f"no replicas info in: {out[:200]}" + + +test("k8s/describe_deployment", test_describe_deployment) + + +# ────────────────────────────────────────────── +# obol-dvt tests +# ────────────────────────────────────────────── +print("\n\033[1m--- obol-dvt ---\033[0m") + + +def test_dvt_files(): + for f in ["SKILL.md", "references/api-examples.md"]: + path = os.path.join(SKILLS_DIR, "obol-dvt", f) + assert os.path.isfile(path), f"missing: {f}" + + +test("dvt/files_exist", test_dvt_files) + + +def curl_json(url): + """Fetch JSON via curl (matches how the DVT skill documents API access).""" + rc, out, err = run(["curl", "-sf", url]) + assert rc == 0, f"curl failed (exit {rc}): {err}" + return json.loads(out) + + +def test_obol_api_health(): + # _health returns 503 when any sub-check is down, so use curl without -f + rc, out, err = run(["curl", "-s", "https://api.obol.tech/v1/_health"]) + assert rc == 0, f"curl failed: {err}" + data = json.loads(out) + assert "status" in data, f"no status field in: {data}" + # mainnet beacon should be up even if other networks aren't + details = data.get("details", data.get("info", {})) + mainnet = details.get("mainnet beacon node health", {}) + assert mainnet.get("status") == "up", f"mainnet beacon not up: {details}" + + +test("dvt/api_health", test_obol_api_health) + + +def test_network_summary(): + data = curl_json("https://api.obol.tech/v1/lock/network/summary/mainnet") + assert isinstance(data, dict), f"expected dict, got {type(data)}" + clusters = data.get("total_clusters", data.get("totalClusters", 0)) + assert clusters > 0, f"total_clusters is 0 or missing: {data}" + + +test("dvt/network_summary", test_network_summary) + + +# ────────────────────────────────────────────── +# Summary +# ────────────────────────────────────────────── +print(f"\n{'='*50}") +if errors: + print("\nFailures:") + for name, msg in errors: + print(f" - {name}: {msg}") + print() + +total = passed + failed +print(f"Results: \033[32m{passed} passed\033[0m, \033[31m{failed} failed\033[0m, {total} total") +sys.exit(1 if failed else 0) From 8b12abbe42da8de8b53070cb75b92cf7eac86677 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 13:36:32 +0400 Subject: [PATCH 5/8] Update docs and developer skill for rich skills integration Replace stale ethereum skill references with the four embedded skills (hello, obol-blockchain, obol-k8s, obol-dvt). Add standalone Skills section to README, update CLAUDE.md delivery flow and source file listings, and expand obol-stack-dev skill with skills system coverage. --- .agents/skills/obol-stack-dev/SKILL.md | 54 +++++++++++++++++++++-- CLAUDE.md | 59 ++++++++++++++++---------- README.md | 23 +++++++++- 3 files changed, 109 insertions(+), 27 deletions(-) diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index 76d2160b..c2441cbe 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -108,20 +108,68 @@ go test ./internal/openclaw/ # Unit tests go test -tags integration -v -timeout 10m ./internal/openclaw/ # Integration tests ``` +## OpenClaw Skills System + +Skills are SKILL.md files (with optional scripts and references) that give the agent domain-specific capabilities. Delivered via host-path PVC injection to `/data/.openclaw/skills/` inside the pod. + +### Default Embedded Skills + +| Skill | Contents | Purpose | +|-------|----------|---------| +| `hello` | `SKILL.md` | Smoke test | +| `obol-blockchain` | `SKILL.md`, `scripts/rpc.py`, `references/` | Ethereum JSON-RPC, ERC-20, ENS via eRPC | +| `obol-k8s` | `SKILL.md`, `scripts/kube.py` | K8s cluster diagnostics via ServiceAccount API | +| `obol-dvt` | `SKILL.md`, `references/api-examples.md` | DVT monitoring via Obol API | + +### Skills CLI + +```bash +obol openclaw skills list # list installed skills +obol openclaw skills sync # re-inject embedded defaults +obol openclaw skills sync --from ./custom # push custom skills +obol openclaw skills add # add via openclaw CLI in pod +obol openclaw skills remove # remove skill from pod +``` + +### Skills Delivery Flow + +1. `stageDefaultSkills(deploymentDir)` — copies embedded skills to deployment dir +2. `injectSkillsToVolume(cfg, id, deploymentDir)` — copies to host PVC path (`$DATA_DIR/openclaw-/openclaw-data/.openclaw/skills/`) +3. `doSync()` — helmfile sync; OpenClaw file watcher discovers skills on startup + +### Skills Testing + +```bash +# Unit tests (embedding + injection) +go test -v -run TestGetEmbeddedSkillNames ./internal/embed/ +go test -v -run TestInjectSkillsToVolume ./internal/openclaw/ + +# Integration tests (requires running cluster) +go test -tags integration -v -run TestIntegration_Skills -timeout 10m ./internal/openclaw/ + +# In-pod smoke tests (piped via kubectl exec) +obol kubectl exec -i -n openclaw- deploy/openclaw -c openclaw -- python3 - < tests/skills_smoke_test.py +``` + ## Key Source Files | File | Purpose | |------|---------| -| `internal/openclaw/openclaw.go` | `Onboard()`, `Sync()`, `Delete()`, `buildLLMSpyRoutedOverlay()`, `generateOverlayValues()` | +| `internal/openclaw/openclaw.go` | `Onboard()`, `Sync()`, `Delete()`, `buildLLMSpyRoutedOverlay()`, `generateOverlayValues()`, `stageDefaultSkills()`, `injectSkillsToVolume()` | | `internal/openclaw/import.go` | `DetectExistingConfig()`, `TranslateToOverlayYAML()` | | `internal/openclaw/overlay_test.go` | Unit tests for overlay generation | -| `internal/openclaw/integration_test.go` | Full-cluster integration tests (build tag: `integration`) | +| `internal/openclaw/skills_injection_test.go` | Unit tests for skill staging and volume injection | +| `internal/openclaw/integration_test.go` | Full-cluster integration tests (build tag: `integration`) — includes skills + inference tests | | `internal/model/model.go` | `ConfigureLLMSpy()` — patches llmspy Secret + ConfigMap + restart | | `cmd/obol/model.go` | `obol model setup` CLI command | -| `cmd/obol/openclaw.go` | `obol openclaw` CLI commands | +| `cmd/obol/openclaw.go` | `obol openclaw` CLI commands (including `skills` subcommands) | | `internal/embed/infrastructure/base/templates/llm.yaml` | llmspy Kubernetes resources | +| `internal/embed/skills/` | Embedded default skills (hello, obol-blockchain, obol-k8s, obol-dvt) | +| `internal/embed/embed.go` | `CopySkills()`, `GetEmbeddedSkillNames()` | +| `internal/embed/embed_skills_test.go` | Unit tests for skill embedding | | `internal/openclaw/chart/values.yaml` | Default per-instance model config | | `internal/openclaw/chart/templates/_helpers.tpl` | Renders model providers into OpenClaw JSON config | +| `tests/skills_smoke_test.py` | In-pod Python smoke tests for all rich skills | ## Constraints diff --git a/CLAUDE.md b/CLAUDE.md index 7786df7d..310c1fd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -770,34 +770,38 @@ models: ### Overview -OpenClaw skills are SKILL.md files that give the AI agent domain-specific capabilities. The Obol Stack ships default skills embedded in the `obol` binary and supports runtime skill management via the openclaw CLI inside the pod. +OpenClaw skills are SKILL.md files (with optional scripts and references) that give the AI agent domain-specific capabilities. The Obol Stack ships default skills embedded in the `obol` binary and supports runtime skill management via the CLI. -### Two Delivery Channels +### Delivery Mechanism: Host-Path PVC Injection -**Compile-time (default skills)**: SKILL.md files embedded in `internal/embed/skills/` are staged to the deployment config directory and pushed as a ConfigMap during `doSync()`. +Skills are delivered by writing directly to the host filesystem at `$DATA_DIR/openclaw-/openclaw-data/.openclaw/skills/`, which maps to `/data/.openclaw/skills/` inside the OpenClaw container via k3d volume mounts and local-path-provisioner. -**Runtime**: `obol openclaw skills add/remove/list` runs the native openclaw CLI inside the pod via `kubectl exec -c openclaw`. Skills are persisted on the pod's PVC. +**Advantages over ConfigMap approach**: No 1MB size limit, works before pod readiness, survives pod restarts, supports binary files and scripts. -### Default Skills (MVP) +### Default Skills -| Skill | Purpose | -|-------|---------| -| `hello` | Smoke test — confirms skills are loaded | -| `ethereum` | Ethereum JSON-RPC queries via the eRPC gateway (`/rpc/`) | +| Skill | Contents | Purpose | +|-------|----------|---------| +| `hello` | `SKILL.md` | Smoke test — confirms skills pipeline works | +| `obol-blockchain` | `SKILL.md`, `scripts/rpc.py`, `references/erc20-methods.md`, `references/common-contracts.md` | Ethereum JSON-RPC queries, ERC-20 token ops, ENS resolution, gas estimation via the eRPC gateway | +| `obol-k8s` | `SKILL.md`, `scripts/kube.py` | Kubernetes cluster diagnostics — pods, logs, events, deployments via ServiceAccount API | +| `obol-dvt` | `SKILL.md`, `references/api-examples.md` | Obol DVT cluster monitoring, operator audit, exit coordination via Obol API | ### Skill Delivery Flow ``` Onboard / Sync: 1. stageDefaultSkills(deploymentDir) - → writes embedded skills to $CONFIG_DIR/applications/openclaw//skills/ - → skips if skills/ directory already exists + → copies embedded skills from internal/embed/skills/ to deploymentDir/skills/ + → skips if skills/ directory already exists (preserves user customizations) - 2. doSync() → helmfile sync (creates namespace, chart, pod) + 2. injectSkillsToVolume(cfg, id, deploymentDir) + → copies skills/ from deployment dir to host PVC path: + $DATA_DIR/openclaw-/openclaw-data/.openclaw/skills/ + → this path is volume-mounted into the pod at /data/.openclaw/skills/ - 3. syncStagedSkills(cfg, id, deploymentDir) - → calls SkillsSync() which packages skills/ into ConfigMap openclaw--skills - → chart mounts ConfigMap into pod, extract-skills init container unpacks it + 3. doSync() → helmfile sync (creates namespace, chart, pod) + → OpenClaw file watcher auto-discovers skills on startup ``` ### Instance Resolution @@ -810,21 +814,25 @@ All `obol openclaw` subcommands (except `onboard` and `list`) use `ResolveInstan ### CLI Commands ```bash -obol openclaw skills list # list installed skills (auto-resolves instance) -obol openclaw skills add # add via openclaw CLI in pod -obol openclaw skills remove # remove via openclaw CLI in pod -obol openclaw skills sync --from # push local dir as ConfigMap (legacy) +obol openclaw skills list # list installed skills (auto-resolves instance) +obol openclaw skills add # add via openclaw CLI in pod +obol openclaw skills remove # remove via openclaw CLI in pod +obol openclaw skills sync # re-inject embedded defaults to volume +obol openclaw skills sync --from # push custom skills from local directory ``` ### Key Source Files | File | Role | |------|------| -| `internal/embed/skills/` | Embedded default SKILL.md files | +| `internal/embed/skills/` | Embedded default SKILL.md files + scripts + references | | `internal/embed/embed.go` | `CopySkills()`, `GetEmbeddedSkillNames()` | +| `internal/embed/embed_skills_test.go` | Unit tests for skill embedding and copying | | `internal/openclaw/resolve.go` | `ResolveInstance()`, `ListInstanceIDs()` | -| `internal/openclaw/openclaw.go` | `stageDefaultSkills()`, `syncStagedSkills()`, `SkillAdd/Remove/List()` | +| `internal/openclaw/openclaw.go` | `stageDefaultSkills()`, `injectSkillsToVolume()`, `skillsVolumePath()`, `SkillAdd/Remove/List/Sync()` | +| `internal/openclaw/skills_injection_test.go` | Unit tests for staging and volume injection | | `cmd/obol/openclaw.go` | CLI wiring for `obol openclaw skills` subcommands | +| `tests/skills_smoke_test.py` | In-pod Python smoke tests for all 3 rich skills | ## Network Install Implementation Details @@ -1081,12 +1089,17 @@ obol network delete ethereum- --force - `aztec/helmfile.yaml.gotmpl` - `internal/embed/defaults/` - Default stack resources - `internal/embed/infrastructure/` - Infrastructure resources (llmspy, Traefik) -- `internal/embed/skills/` - Default OpenClaw skills (hello, ethereum) embedded in obol binary +- `internal/embed/skills/` - Default OpenClaw skills (hello, obol-blockchain, obol-k8s, obol-dvt) embedded in obol binary **Skills system**: - `internal/openclaw/resolve.go` - Smart instance resolution (0/1/2+ instances) - `internal/embed/skills/hello/SKILL.md` - Hello world smoke-test skill -- `internal/embed/skills/ethereum/SKILL.md` - Ethereum JSON-RPC via eRPC skill +- `internal/embed/skills/obol-blockchain/` - Ethereum JSON-RPC, ERC-20, ENS via eRPC (SKILL.md + scripts/rpc.py + references/) +- `internal/embed/skills/obol-k8s/` - Kubernetes cluster diagnostics (SKILL.md + scripts/kube.py) +- `internal/embed/skills/obol-dvt/` - DVT cluster monitoring via Obol API (SKILL.md + references/api-examples.md) +- `internal/embed/embed_skills_test.go` - Unit tests for skill embedding +- `internal/openclaw/skills_injection_test.go` - Unit tests for skill staging and injection +- `tests/skills_smoke_test.py` - In-pod Python smoke tests for all rich skills **Testing**: - `internal/openclaw/integration_test.go` - Full-cluster integration tests (Ollama, Anthropic, OpenAI inference through llmspy) diff --git a/README.md b/README.md index 0d16f6fe..c8c8380a 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,28 @@ obol openclaw delete --force When only one OpenClaw instance is installed, the instance ID is optional — it is auto-selected. With multiple instances, specify the name: `obol openclaw setup prod`. -Default Obol skills (`hello`, `ethereum`) are installed automatically on first deploy and provide the agent with eRPC JSON-RPC access and a basic smoke test. +### Skills + +OpenClaw ships with four embedded skills that are installed automatically on first deploy: + +| Skill | Purpose | +|-------|---------| +| `hello` | Smoke test — confirms skills pipeline works | +| `obol-blockchain` | Ethereum JSON-RPC queries, ERC-20 token ops, ENS resolution via the eRPC gateway | +| `obol-k8s` | Kubernetes cluster diagnostics — pods, logs, events, deployments | +| `obol-dvt` | Obol DVT cluster monitoring, operator audit, exit coordination | + +Manage skills at runtime: + +```bash +obol openclaw skills list # list installed skills +obol openclaw skills sync # re-inject embedded defaults +obol openclaw skills sync --from ./my-skills # push custom skills from local dir +obol openclaw skills add # add via openclaw CLI in pod +obol openclaw skills remove # remove via openclaw CLI in pod +``` + +Skills are delivered via host-path PVC injection — no ConfigMap size limits, works before pod readiness, and survives pod restarts. ## Public Access (Cloudflare Tunnel) From 0b75f8a1821805b6f4a67cf9269b0425b9eaa6a4 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 20 Feb 2026 14:29:53 +0400 Subject: [PATCH 6/8] Fix Z.AI model ID and skills sync instance resolution in integration tests - Use glm-5 instead of glm-4-flash (not in providers.json) - Pass explicit instance ID to skills sync when multiple instances exist --- internal/openclaw/integration_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index c12e2aee..305d4fb6 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -507,8 +507,8 @@ func TestIntegration_ZaiInference(t *testing.T) { cloud := &CloudProviderInfo{ Name: "zai", APIKey: apiKey, - ModelID: "glm-4-flash", - Display: "GLM-4 Flash", + ModelID: "glm-5", + Display: "GLM-5", } // Scaffold cloud overlay + deploy via obol openclaw sync @@ -525,7 +525,7 @@ func TestIntegration_ZaiInference(t *testing.T) { t.Logf("retrieved gateway token (%d chars)", len(token)) baseURL := portForward(t, cfg, namespace) - agentModel := "ollama/glm-4-flash" // routed through llmspy + agentModel := "ollama/glm-5" // routed through llmspy t.Logf("testing inference with model %s at %s", agentModel, baseURL) reply := chatCompletion(t, baseURL, agentModel, token) @@ -712,9 +712,11 @@ func TestIntegration_SkillsSync(t *testing.T) { t.Fatalf("failed to write custom SKILL.md: %v", err) } - // Sync custom skills via obol openclaw skills sync - t.Log("syncing custom skills via: obol openclaw skills sync --from " + customSkillsDir) - obolRun(t, cfg, "openclaw", "skills", "sync", "--from", customSkillsDir) + // Sync custom skills via obol openclaw skills sync (explicit instance ID + // required when multiple instances exist, e.g. "default" + test instance). + // Flags must precede the positional arg for urfave/cli. + t.Log("syncing custom skills via: obol openclaw skills sync --from " + customSkillsDir + " " + id) + obolRun(t, cfg, "openclaw", "skills", "sync", "--from", customSkillsDir, id) // Verify custom skill landed in the volume path volumePath := skillsVolumePath(cfg, id) From b76fbb9e4fbff3ce50df0d80a631098482e53b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 11:35:35 +0000 Subject: [PATCH 7/8] Change the name on skills and test --- internal/embed/embed_skills_test.go | 26 +- .../skills/distributed-validators/SKILL.md | 137 ++++++++++ .../references/api-examples.md | 0 .../embed/skills/ethereum-networks/SKILL.md | 112 ++++++++ .../references/common-contracts.md | 0 .../references/erc20-methods.md | 0 .../scripts/rpc.py | 0 .../embed/skills/ethereum-wallet/SKILL.md | 38 +++ internal/embed/skills/hello/SKILL.md | 12 - .../embed/skills/obol-blockchain/SKILL.md | 194 -------------- internal/embed/skills/obol-dvt/SKILL.md | 247 ------------------ internal/embed/skills/obol-k8s/SKILL.md | 148 ----------- internal/embed/skills/obol-stack/SKILL.md | 96 +++++++ .../{obol-k8s => obol-stack}/scripts/kube.py | 0 internal/openclaw/integration_test.go | 30 +-- internal/openclaw/skills_injection_test.go | 12 +- 16 files changed, 417 insertions(+), 635 deletions(-) create mode 100644 internal/embed/skills/distributed-validators/SKILL.md rename internal/embed/skills/{obol-dvt => distributed-validators}/references/api-examples.md (100%) create mode 100644 internal/embed/skills/ethereum-networks/SKILL.md rename internal/embed/skills/{obol-blockchain => ethereum-networks}/references/common-contracts.md (100%) rename internal/embed/skills/{obol-blockchain => ethereum-networks}/references/erc20-methods.md (100%) rename internal/embed/skills/{obol-blockchain => ethereum-networks}/scripts/rpc.py (100%) create mode 100644 internal/embed/skills/ethereum-wallet/SKILL.md delete mode 100644 internal/embed/skills/hello/SKILL.md delete mode 100644 internal/embed/skills/obol-blockchain/SKILL.md delete mode 100644 internal/embed/skills/obol-dvt/SKILL.md delete mode 100644 internal/embed/skills/obol-k8s/SKILL.md create mode 100644 internal/embed/skills/obol-stack/SKILL.md rename internal/embed/skills/{obol-k8s => obol-stack}/scripts/kube.py (100%) diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go index fe6cf4fe..a0167054 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -13,7 +13,7 @@ func TestGetEmbeddedSkillNames(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - want := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + want := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} sort.Strings(names) if len(names) != len(want) { @@ -34,7 +34,7 @@ func TestCopySkills(t *testing.T) { } // Every skill must have a SKILL.md - skills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + skills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} for _, skill := range skills { skillMD := filepath.Join(destDir, skill, "SKILL.md") info, err := os.Stat(skillMD) @@ -47,25 +47,25 @@ func TestCopySkills(t *testing.T) { } } - // obol-blockchain must have scripts/rpc.py and references/ + // ethereum-networks must have scripts/rpc.py and references/ for _, sub := range []string{ - "obol-blockchain/scripts/rpc.py", - "obol-blockchain/references/erc20-methods.md", - "obol-blockchain/references/common-contracts.md", + "ethereum-networks/scripts/rpc.py", + "ethereum-networks/references/erc20-methods.md", + "ethereum-networks/references/common-contracts.md", } { if _, err := os.Stat(filepath.Join(destDir, sub)); err != nil { t.Errorf("missing %s: %v", sub, err) } } - // obol-k8s must have scripts/kube.py - if _, err := os.Stat(filepath.Join(destDir, "obol-k8s", "scripts", "kube.py")); err != nil { - t.Errorf("missing obol-k8s/scripts/kube.py: %v", err) + // obol-stack must have scripts/kube.py + if _, err := os.Stat(filepath.Join(destDir, "obol-stack", "scripts", "kube.py")); err != nil { + t.Errorf("missing obol-stack/scripts/kube.py: %v", err) } - // obol-dvt must have references/api-examples.md - if _, err := os.Stat(filepath.Join(destDir, "obol-dvt", "references", "api-examples.md")); err != nil { - t.Errorf("missing obol-dvt/references/api-examples.md: %v", err) + // distributed-validators must have references/api-examples.md + if _, err := os.Stat(filepath.Join(destDir, "distributed-validators", "references", "api-examples.md")); err != nil { + t.Errorf("missing distributed-validators/references/api-examples.md: %v", err) } } @@ -73,7 +73,7 @@ func TestCopySkillsSkipsExisting(t *testing.T) { destDir := t.TempDir() // Pre-create a skill directory with custom content - customDir := filepath.Join(destDir, "hello") + customDir := filepath.Join(destDir, "ethereum-wallet") if err := os.MkdirAll(customDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } diff --git a/internal/embed/skills/distributed-validators/SKILL.md b/internal/embed/skills/distributed-validators/SKILL.md new file mode 100644 index 00000000..c839279c --- /dev/null +++ b/internal/embed/skills/distributed-validators/SKILL.md @@ -0,0 +1,137 @@ +--- +name: distributed-validators +description: "Monitor distributed validator clusters via the Obol API. Use when asked about DVT cluster health, validator performance, operator status, exit coordination, or anything related to Obol, Charon, or distributed validators. Read-only — cannot create clusters or submit exits." +metadata: { "openclaw": { "emoji": "🔱", "requires": { "bins": ["curl"] } } } +--- + +# Distributed Validators + +Monitor distributed validator (DV) clusters using the Obol API. Check cluster health, validator performance, operator status, and exit progress. + +A distributed validator splits signing across multiple operators so no single party holds the full key. Obol's middleware (Charon) coordinates this. A cluster has N operators with a threshold (e.g. 3-of-4) needed to sign. + +## When to Use + +- Checking cluster health or validator performance +- Looking up operator reputation or badges +- Checking exit coordination progress +- Exploring network-wide DV statistics + +## When NOT to Use + +- Creating clusters or running DKG (write operations) +- Ethereum RPC queries — use `ethereum-networks` +- Kubernetes diagnostics — use `obol-stack` + +## API Base + +All endpoints are public, no authentication needed: + +``` +https://api.obol.tech +``` + +## Key Identifiers + +- **lock_hash**: Identifies a cluster after key generation (post-DKG). `0x` + 64 hex chars. +- **config_hash**: Identifies a cluster definition before key generation (pre-DKG). +- **operator address**: Standard Ethereum address, `0x` + 40 hex chars. + +## Lookup by lock_hash + +```bash +# Cluster config +curl -s "https://api.obol.tech/v1/lock/0x..." + +# Validator performance (0-1 scale, >0.95 is healthy) +curl -s "https://api.obol.tech/v1/effectiveness/0x..." + +# Validator beacon chain states +curl -s "https://api.obol.tech/v1/state/0x..." + +# Exit status summary +curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..." + +# Detailed exit status +curl -s "https://api.obol.tech/v1/exp/exit/status/0x..." +``` + +## Lookup by config_hash + +```bash +# Pre-DKG cluster definition +curl -s "https://api.obol.tech/v1/definition/0x..." + +# Get lock (if DKG completed) +curl -s "https://api.obol.tech/v1/lock/configHash/0x..." +``` + +## Lookup by operator address + +```bash +# All clusters for this operator +curl -s "https://api.obol.tech/v1/lock/operator/0x..." + +# Reputation level (base < bronze < silver) +curl -s "https://api.obol.tech/v1/address/techne/0x..." + +# Protocol badges (Lido, EtherFi, etc.) +curl -s "https://api.obol.tech/v1/address/badges/0x..." + +# Terms & conditions signed? +curl -s "https://api.obol.tech/v1/termsAndConditions/0x..." +``` + +## Network-wide queries + +```bash +# Network summary stats +curl -s "https://api.obol.tech/v1/lock/network/summary/mainnet" + +# Search clusters +curl -s "https://api.obol.tech/v1/lock/search/mainnet?q=..." + +# All operators on a network +curl -s "https://api.obol.tech/v1/address/network/mainnet" +``` + +Networks: `mainnet`, `hoodi`, `holesky`, `sepolia` + +## Common Workflows + +### Check cluster health + +```bash +# 1. Get cluster info +curl -s "https://api.obol.tech/v1/lock/0x..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +print(f'Cluster: {d.get(\"name\",\"?\")} | {d.get(\"threshold\",\"?\")}-of-{len(d.get(\"operators\",[]))} | {d.get(\"network\",\"?\")}')" + +# 2. Check validator effectiveness +curl -s "https://api.obol.tech/v1/effectiveness/0x..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +for v in d.get('effectiveness',[]): + eff = v.get('effectiveness',0) + status = 'healthy' if eff > 0.95 else 'degraded' if eff > 0.8 else 'CRITICAL' + print(f'{v.get(\"public_key\",\"?\")[:16]}... {eff:.3f} [{status}]')" +``` + +### Check exit progress + +```bash +curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..." | python3 -c " +import sys,json; d=json.load(sys.stdin) +print(f'Ready to exit: {d.get(\"validators_ready_to_exit\",0)}/{d.get(\"total_validators\",0)}') +for op in d.get('operators',[]): + print(f' {op[\"address\"][:12]}... signed: {op.get(\"signed_exits\",0)}')" +``` + +Exit broadcasts automatically once enough operators have signed (threshold reached). + +## Constraints + +- **Read-only** — creating clusters, running DKG, and submitting exits require authenticated endpoints +- Exit status endpoints (`/v1/exp/`) are experimental — pagination is 1-indexed +- If timeouts occur, check `GET /v1/_health` first + +See `references/api-examples.md` for response shapes and field reference. diff --git a/internal/embed/skills/obol-dvt/references/api-examples.md b/internal/embed/skills/distributed-validators/references/api-examples.md similarity index 100% rename from internal/embed/skills/obol-dvt/references/api-examples.md rename to internal/embed/skills/distributed-validators/references/api-examples.md diff --git a/internal/embed/skills/ethereum-networks/SKILL.md b/internal/embed/skills/ethereum-networks/SKILL.md new file mode 100644 index 00000000..02ee6753 --- /dev/null +++ b/internal/embed/skills/ethereum-networks/SKILL.md @@ -0,0 +1,112 @@ +--- +name: ethereum-networks +description: "Query Ethereum networks through the local RPC gateway. Use when asked about blocks, balances, transactions, gas prices, token balances, or any eth_* JSON-RPC method. All queries are read-only and routed through the in-cluster eRPC load balancer." +metadata: { "openclaw": { "emoji": "⛓️", "requires": { "bins": ["curl", "python3"] } } } +--- + +# Ethereum Networks + +Query Ethereum blockchain data through the local eRPC gateway. Supports any JSON-RPC method, multiple networks, and ERC-20 token lookups. + +## When to Use + +- Block numbers, balances, gas prices, chain IDs +- Transaction lookups and receipts +- Smart contract reads (eth_call) +- Token balance and info queries +- Any `eth_*`, `net_*`, or `web3_*` method + +## When NOT to Use + +- Sending transactions or signing (read-only, no private keys) +- Validator monitoring — use `distributed-validators` +- Kubernetes pod diagnostics — use `obol-stack` + +## RPC Gateway + +The eRPC gateway routes to whichever Ethereum networks are installed: + +``` +http://erpc.erpc.svc.cluster.local:4000/rpc/{network} +``` + +`mainnet` is always available. Other networks (e.g. `hoodi`) are available if installed. You can also use `evm/{chainId}` (e.g. `evm/560048` for Hoodi). + +To see which networks are connected: + +```bash +curl -s http://erpc.erpc.svc.cluster.local:4000/ | python3 -m json.tool +``` + +## Quick Start + +```bash +# Block number +python3 scripts/rpc.py eth_blockNumber + +# On a different network +python3 scripts/rpc.py --network hoodi eth_blockNumber + +# ETH balance +python3 scripts/rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + +# Gas price +python3 scripts/rpc.py eth_gasPrice + +# Chain ID +python3 scripts/rpc.py eth_chainId + +# Read a contract (e.g. ERC-20 totalSupply) +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd +``` + +## Supported Methods + +| Method | Params | Returns | +|--------|--------|---------| +| `eth_blockNumber` | none | Latest block number | +| `eth_getBalance` | `address [block]` | Balance (auto-converted to ETH) | +| `eth_gasPrice` | none | Gas price (auto-converted to Gwei) | +| `eth_chainId` | none | Chain ID | +| `eth_getBlockByNumber` | `blockNum includeTxs` | Block data | +| `eth_getTransactionByHash` | `txHash` | Transaction details | +| `eth_getTransactionReceipt` | `txHash` | Receipt with logs and status | +| `eth_call` | `to data [block]` | Contract read result | +| `eth_estimateGas` | `to data [from] [value]` | Gas estimate | +| `eth_getLogs` | `fromBlock toBlock [address] [topic0]` | Event logs | + +## Token Queries + +Use `eth_call` with the token contract address and function selector: + +```bash +# balanceOf — pad address to 32 bytes +python3 scripts/rpc.py eth_call \ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ + 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + +# name() +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x06fdde03 + +# decimals() +python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x313ce567 +``` + +See `references/erc20-methods.md` for the full selector reference and `references/common-contracts.md` for well-known addresses. + +## Direct curl + +When the helper script doesn't cover a method: + +```bash +curl -s -X POST "$ERPC_URL/mainnet" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + | python3 -c "import sys,json; r=json.load(sys.stdin); print(int(r['result'],16) if 'result' in r else r)" +``` + +## Constraints + +- **Read-only** — no private keys, no signing, no state changes +- **Local routing** — always use eRPC (`$ERPC_URL`), never call external providers +- **Hex encoding** — JSON-RPC uses hex; the helper script auto-converts common cases diff --git a/internal/embed/skills/obol-blockchain/references/common-contracts.md b/internal/embed/skills/ethereum-networks/references/common-contracts.md similarity index 100% rename from internal/embed/skills/obol-blockchain/references/common-contracts.md rename to internal/embed/skills/ethereum-networks/references/common-contracts.md diff --git a/internal/embed/skills/obol-blockchain/references/erc20-methods.md b/internal/embed/skills/ethereum-networks/references/erc20-methods.md similarity index 100% rename from internal/embed/skills/obol-blockchain/references/erc20-methods.md rename to internal/embed/skills/ethereum-networks/references/erc20-methods.md diff --git a/internal/embed/skills/obol-blockchain/scripts/rpc.py b/internal/embed/skills/ethereum-networks/scripts/rpc.py similarity index 100% rename from internal/embed/skills/obol-blockchain/scripts/rpc.py rename to internal/embed/skills/ethereum-networks/scripts/rpc.py diff --git a/internal/embed/skills/ethereum-wallet/SKILL.md b/internal/embed/skills/ethereum-wallet/SKILL.md new file mode 100644 index 00000000..531b142e --- /dev/null +++ b/internal/embed/skills/ethereum-wallet/SKILL.md @@ -0,0 +1,38 @@ +--- +name: ethereum-wallet +description: "Sign and send Ethereum transactions via a remote Web3Signer. This skill is not yet implemented — it will connect to a Web3Signer instance using a URI and auth token to sign transactions, deploy contracts, and manage validator operations." +metadata: { "openclaw": { "emoji": "🔐", "requires": { "bins": ["curl"] } } } +--- + +# Ethereum Wallet + +> **This skill is coming soon.** It is not yet functional — the instructions below describe what it will do when complete. + +## What This Will Do + +The ethereum-wallet skill will let you sign and send Ethereum transactions through a remote [Web3Signer](https://docs.web3signer.consensys.io/) instance. Web3Signer is a remote signing service that keeps private keys secure and separate from the application. + +### Planned capabilities + +- **Send ETH** to any address +- **Call contract functions** that modify state (not just read — that's what `ethereum-networks` does) +- **Deploy contracts** from bytecode +- **Sign messages** for off-chain verification +- **Manage validator operations** like voluntary exits + +### Configuration + +When ready, this skill will need two things: + +1. **Web3Signer URI** — the URL of your Web3Signer instance (e.g. `http://web3signer.svc.cluster.local:9000`) +2. **Auth token** — a bearer token for authenticating with the signer + +These will be provided during setup via `obol openclaw setup` or environment variables. + +## Current Status + +This skill is a placeholder. If you need to: + +- **Read** blockchain data (balances, blocks, transactions) — use the `ethereum-networks` skill +- **Monitor** distributed validators — use the `distributed-validators` skill +- **Sign transactions** — this will need to wait until the ethereum-wallet skill is implemented diff --git a/internal/embed/skills/hello/SKILL.md b/internal/embed/skills/hello/SKILL.md deleted file mode 100644 index 7aba25ad..00000000 --- a/internal/embed/skills/hello/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: hello -description: A simple greeting skill to verify skills are working ---- - -# Hello World - -When the user asks you to say hello or test the skills system, respond with a friendly greeting confirming that the Obol Stack skills are loaded and working. - -## Usage -- Respond to "hello", "hi", "test skills", or similar prompts -- Confirm the skill loaded correctly and the agent can see it diff --git a/internal/embed/skills/obol-blockchain/SKILL.md b/internal/embed/skills/obol-blockchain/SKILL.md deleted file mode 100644 index 8943e518..00000000 --- a/internal/embed/skills/obol-blockchain/SKILL.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -name: obol-blockchain -description: "Blockchain RPC and Ethereum operations via local eRPC gateway. Use when: querying blocks, balances, transactions, contract state, token balances, ENS names, gas prices, or any eth_* method. Handles JSON-RPC encoding, hex conversion, and ABI decoding. Routes all queries through the in-cluster eRPC load balancer. NOT for: sending transactions, deploying contracts, DVT/validator operations (use obol-dvt), or Kubernetes operations (use obol-k8s)." -metadata: { "openclaw": { "emoji": "⛓️", "requires": { "bins": ["curl", "python3"] } } } ---- - -# Obol Blockchain - -Query Ethereum blockchain state through the local eRPC gateway. Covers raw JSON-RPC methods, ERC-20 token operations, ENS resolution, gas estimation, and transaction analysis. - -## When to Use - -- "What's the latest block number?" -- "Check balance of 0x..." -- "Read contract state / call a view function" -- "What are the token balances for this address?" -- "Resolve an ENS name" -- "Estimate gas for this call" -- "Look up a transaction receipt" -- Any `eth_*`, `net_*`, or `web3_*` JSON-RPC method - -## When NOT to Use - -- Sending transactions or signing — no private keys available (read-only) -- Deploying contracts — no write access -- DVT cluster monitoring — use `obol-dvt` -- Kubernetes pod health — use `obol-k8s` - -## Environment - -The eRPC gateway supports two URL path formats: - -``` -Alias: http://erpc.erpc.svc.cluster.local:4000/rpc/{alias} e.g. /rpc/mainnet -Explicit: http://erpc.erpc.svc.cluster.local:4000/rpc/evm/{chainId} e.g. /rpc/evm/1 -``` - -`mainnet` alias is always configured. Other network aliases (e.g. `hoodi`) are only available if that Ethereum network has been installed. As a fallback, you can use the explicit `evm/{chainId}` format — for example `/rpc/evm/560048` for Hoodi. - -To discover which networks are currently connected to eRPC: - -```bash -curl -s http://erpc.erpc.svc.cluster.local:4000/ | python3 -m json.tool -``` - -Each project ID in the response is a network alias you can query via `/rpc/{alias}`. - -The helper script defaults to `mainnet`. Override with `--network` flag or `ERPC_NETWORK` env var. The script accepts both aliases (`mainnet`) and explicit paths (`evm/560048`). - -## Quick Start - -```bash -# Block number (mainnet default) -python3 scripts/rpc.py eth_blockNumber - -# Block number on hoodi testnet (use evm/chainId if alias not configured) -python3 scripts/rpc.py --network hoodi eth_blockNumber -python3 scripts/rpc.py --network evm/560048 eth_blockNumber - -# Balance (returns ETH) -python3 scripts/rpc.py eth_getBalance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - -# Gas price (returns Gwei) -python3 scripts/rpc.py eth_gasPrice - -# Chain ID -python3 scripts/rpc.py eth_chainId - -# Contract read (ERC-20 totalSupply) -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd -``` - -## JSON-RPC Methods - -| Method | Params | Returns | -|--------|--------|---------| -| `eth_blockNumber` | none | Latest block number | -| `eth_getBalance` | `address [block]` | Balance in wei (script converts to ETH) | -| `eth_gasPrice` | none | Gas price in wei (script converts to Gwei) | -| `eth_chainId` | none | Chain ID (1=mainnet, 560048=hoodi) | -| `eth_getBlockByNumber` | `blockNum includeTxs` | Block data | -| `eth_getTransactionByHash` | `txHash` | Transaction details | -| `eth_getTransactionReceipt` | `txHash` | Receipt with logs and status | -| `eth_call` | `to data [block]` | Contract read result | -| `eth_estimateGas` | `to data [from] [value]` | Gas estimate | -| `eth_getLogs` | `fromBlock toBlock [address] [topic0]` | Event logs | -| `net_version` | none | Network ID | - -## Token Operations - -Read ERC-20 token state using `eth_call` with the contract address and function selector. - -### Check Token Balance - -```bash -# balanceOf(address) selector: 0x70a08231 -# Pad address to 32 bytes (left-pad with zeros, remove 0x prefix) -python3 scripts/rpc.py eth_call \ - 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ - 0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -``` - -### Get Token Info - -```bash -# name() -> 0x06fdde03 -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x06fdde03 - -# symbol() -> 0x95d89b41 -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x95d89b41 - -# decimals() -> 0x313ce567 -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x313ce567 - -# totalSupply() -> 0x18160ddd -python3 scripts/rpc.py eth_call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0x18160ddd -``` - -See `references/erc20-methods.md` for the complete function selector reference and ABI encoding guide. - -## ENS Resolution (Mainnet Only) - -ENS names resolve through the ENS registry at `0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e`. - -```bash -# Step 1: Get resolver for a name (namehash required) -# Step 2: Call resolver.addr(namehash) to get the address - -# For common names, use the public resolver directly: -# PublicResolver: 0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63 -# addr(bytes32 node) selector: 0x3b3b57de -``` - -ENS resolution requires computing the namehash using **Keccak-256** (Ethereum's hash function). - -**Warning:** Python's `hashlib.sha3_256` is NIST SHA-3, NOT Keccak-256. They use different internal padding and produce different outputs. Do not use `hashlib.sha3_256` for ENS namehash — it will return wrong results. - -Computing namehash correctly requires a Keccak-256 library (e.g., `pysha3`, `pycryptodome`, or `ethers.js`). Since these aren't available in the pod, ENS resolution is limited to names with known namehashes or external lookup services. - -## Gas Estimation - -```bash -# Estimate gas for a transfer -python3 scripts/rpc.py eth_estimateGas \ - 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ - 0xa9059cbb000000000000000000000000... - -# Current gas price -python3 scripts/rpc.py eth_gasPrice - -# Total cost estimate: gasEstimate * gasPrice -``` - -## Transaction Analysis - -```bash -# Get transaction details -python3 scripts/rpc.py eth_getTransactionByHash 0xabc123... - -# Get receipt with logs -python3 scripts/rpc.py eth_getTransactionReceipt 0xabc123... -``` - -Receipt fields: `status` (0x1=success, 0x0=revert), `gasUsed`, `logs[]` (events emitted). - -## Direct curl - -When the helper script doesn't cover a method or you need custom params: - -```bash -# Mainnet -curl -s -X POST "$ERPC_URL/mainnet" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - | python3 -c "import sys,json; r=json.load(sys.stdin); print(int(r['result'],16) if 'result' in r else r)" - -# Hoodi testnet (alias — requires hoodi network installed) -curl -s -X POST "$ERPC_URL/hoodi" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' - -# Hoodi testnet (explicit chain ID — always works) -curl -s -X POST "$ERPC_URL/evm/560048" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' -``` - -## Constraints - -- **Read-only** — no private keys, no transaction signing, no state mutations -- **Local routing** — always use eRPC (`$ERPC_URL`), never call external RPC providers directly -- **Hex encoding** — JSON-RPC uses hex for numbers and bytes; the helper script converts common cases -- **Block parameter** — `latest` (default), `earliest`, `pending`, or hex block number -- See `references/common-contracts.md` for well-known contract addresses diff --git a/internal/embed/skills/obol-dvt/SKILL.md b/internal/embed/skills/obol-dvt/SKILL.md deleted file mode 100644 index ac1a6836..00000000 --- a/internal/embed/skills/obol-dvt/SKILL.md +++ /dev/null @@ -1,247 +0,0 @@ ---- -name: obol-dvt -description: "Distributed Validator (DVT) cluster monitoring, operator management, and exit coordination via Obol Network API. Use when: querying DVT clusters, checking validator performance, investigating operator status, coordinating exits, or discussing Obol/Charon/DKG concepts. Uses mcporter MCP tools if configured, falls back to direct Obol API calls via curl. NOT for: creating clusters, running DKG, or submitting exits (write operations)." -metadata: { "openclaw": { "emoji": "🔱", "requires": { "bins": ["curl"] } } } ---- - -# Obol Distributed Validator (DV) Skill - -Query and monitor Distributed Validators on the Obol Network. Covers cluster health, operator management, exit coordination, and DVT concepts. - ---- - -## What is a Distributed Validator? - -A **Distributed Validator (DV)** is an Ethereum validator whose private key is never held -by a single party. Instead, the signing key is split across a group of **operators** using -threshold BLS cryptography. Any `threshold-of-N` operators must cooperate to produce a valid -signature — so the validator keeps attesting even if some operators go offline, and no single -operator can act maliciously on their own. - -Obol's open-source middleware is called **Charon**. Each operator runs a Charon client -alongside their validator client (e.g., Lighthouse, Teku). Charon handles the consensus -protocol between operators so the validator appears as a single validator to the beacon chain. - -| Term | Meaning | -|------|---------| -| **Cluster** | A group of N operators running DVs together | -| **Threshold** | Minimum operators needed to sign (e.g., 3-of-4) | -| **DKG** | Distributed Key Generation — operators collaboratively create the shared key without anyone seeing the full private key | -| **Cluster Definition** | Pre-DKG proposal: who the operators are, how many validators, which network | -| **Cluster Lock** | Post-DKG artifact: locked configuration + generated validator public keys; identified by `lock_hash` | -| **config_hash** | Hash of the cluster definition (pre-DKG); also embedded in the lock | -| **lock_hash** | Hash of the cluster lock (post-DKG); the primary identifier for a running cluster | -| **Operator** | An Ethereum address that participates in one or more DV clusters | -| **Techne** | Obol's operator reputation system: base > bronze > silver | -| **OWR** | Optimistic Withdrawal Recipient — a smart contract that splits validator rewards | - ---- - -## Cluster Lifecycle - -``` -[Cluster Definition] -> operators agree on config, sign T&Cs - | - [DKG Ceremony] -> Charon nodes exchange key shares; no full key ever assembled - | - [Cluster Lock] -> validator pubkeys generated; cluster is live on beacon chain - | - [Active Validators] -> attesting, proposing blocks, earning rewards - | - [Exit Coordination] -> operators sign exit messages; broadcast when threshold reached -``` - -When a user provides a `config_hash`, they are referring to something at or before DKG. -When they provide a `lock_hash`, the cluster has completed DKG and may have active validators. - ---- - -## API Access - -**Base URL:** `https://api.obol.tech` (public, no authentication needed) - -### Preferred: mcporter MCP tools - -If mcporter is configured with the obol-mcp server: - -```bash -mcporter call obol.obol_cluster_lock_by_hash lock_hash=0x4d6e7f8a... -mcporter call obol.obol_cluster_effectiveness lock_hash=0x4d6e7f8a... -``` - -Check availability: `mcporter list obol 2>/dev/null` - -### Fallback: Direct curl - -```bash -# Helper function for Obol API calls -obol_api() { - curl -s "https://api.obol.tech$1" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin),indent=2))" -} - -# Example -obol_api "/v1/lock/0x4d6e7f8a..." -``` - ---- - -## Tool Selection Guide - -### "I have a lock_hash" - -| Goal | API Endpoint | curl | -|------|-------------|------| -| Cluster config | `GET /v1/lock/{lockHash}` | `curl -s "https://api.obol.tech/v1/lock/0x..."` | -| Validator performance | `GET /v1/effectiveness/{lockHash}` | `curl -s "https://api.obol.tech/v1/effectiveness/0x..."` | -| Validator beacon states | `GET /v1/state/{lockHash}` | `curl -s "https://api.obol.tech/v1/state/0x..."` | -| Exit status summary | `GET /v1/exp/exit/status/summary/{lockHash}` | `curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..."` | -| Detailed exit status | `GET /v1/exp/exit/status/{lockHash}` | `curl -s "https://api.obol.tech/v1/exp/exit/status/0x..."` | - -### "I have a config_hash" - -| Goal | API Endpoint | curl | -|------|-------------|------| -| Pre-DKG definition | `GET /v1/definition/{configHash}` | `curl -s "https://api.obol.tech/v1/definition/0x..."` | -| Cluster lock (if DKG done) | `GET /v1/lock/configHash/{configHash}` | `curl -s "https://api.obol.tech/v1/lock/configHash/0x..."` | - -### "I have an operator address (0x...)" - -| Goal | API Endpoint | -|------|-------------| -| All clusters | `GET /v1/lock/operator/{address}` | -| Cluster definitions | `GET /v1/definition/operator/{address}` | -| Badges (Lido, EtherFi) | `GET /v1/address/badges/{address}` | -| Techne credential level | `GET /v1/address/techne/{address}` | -| Token incentives | `GET /v1/address/incentives/{network}/{address}` | -| T&Cs signed? | `GET /v1/termsAndConditions/{address}` | - -### "I want to explore a network (mainnet / holesky / sepolia)" - -| Goal | API Endpoint | -|------|-------------| -| All clusters | `GET /v1/lock/network/{network}` | -| Network statistics | `GET /v1/lock/network/summary/{network}` | -| Search clusters | `GET /v1/lock/search/{network}?q=...` | -| All operators | `GET /v1/address/network/{network}` | -| Search operators | `GET /v1/address/search/{network}?q=...` | - -### Other - -| Goal | API Endpoint | -|------|-------------| -| Migrateable validators | `GET /v1/address/migrateable-validators/{network}/{withdrawalAddress}` | -| OWR tranches | `GET /v1/owr/{network}/{address}` | -| API health | `GET /v1/_health` | - ---- - -## Common Workflows - -### Investigate a cluster's health - -```bash -# 1. Get cluster config -curl -s "https://api.obol.tech/v1/lock/0x4d6e7f8a..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -print(f'Cluster: {d.get(\"name\",\"?\")} on {d.get(\"network\",\"?\")}') -print(f'Threshold: {d.get(\"threshold\",\"?\")}-of-{len(d.get(\"operators\",[]))}') -print(f'Validators: {d.get(\"num_validators\",len(d.get(\"validators\",[])))}') -" - -# 2. Check effectiveness -curl -s "https://api.obol.tech/v1/effectiveness/0x4d6e7f8a..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -for v in d.get('effectiveness',[]): - eff = v.get('effectiveness',0) - pk = v.get('public_key','?')[:16] - status = 'healthy' if eff > 0.95 else 'degraded' if eff > 0.8 else 'CRITICAL' - print(f'{pk}... {eff:.3f} [{status}]') -" - -# 3. Check validator states -curl -s "https://api.obol.tech/v1/state/0x4d6e7f8a..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -for v in d.get('validators',[]): - bal = int(v.get('balance','0')) / 1e9 - print(f'{v[\"public_key\"][:16]}... {v.get(\"status\",\"?\")} {bal:.4f} ETH') -" -``` - -If effectiveness is low: one or more operators offline, misconfigured Charon, network latency, or a validator stuck in `exiting` state. - -### Coordinate a voluntary exit - -```bash -# 1. Exit status summary -curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -print(f'Ready to exit: {d.get(\"validators_ready_to_exit\",0)}/{d.get(\"total_validators\",0)}') -for op in d.get('operators',[]): - print(f' {op[\"address\"][:12]}... signed: {op.get(\"signed_exits\",0)}') -" -``` - -Exit is broadcast automatically once `threshold` operators have submitted their exit signature shares. - -### Audit an operator - -```bash -# Techne level -curl -s "https://api.obol.tech/v1/address/techne/0xAbCd..." | python3 -c " -import sys,json; d=json.load(sys.stdin); print(f'Level: {d.get(\"credential_level\",\"?\")}')" - -# Badges -curl -s "https://api.obol.tech/v1/address/badges/0xAbCd..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -badges = [b['type'] for b in d.get('badges',[])] -print(f'Badges: {badges if badges else \"none\"}')" - -# Cluster count -curl -s "https://api.obol.tech/v1/lock/operator/0xAbCd..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -clusters = d if isinstance(d,list) else d.get('items',[]) -print(f'Active in {len(clusters)} cluster(s)')" - -# T&Cs signed? -curl -s "https://api.obol.tech/v1/termsAndConditions/0xAbCd..." | python3 -c " -import sys,json; d=json.load(sys.stdin); print(f'T&Cs signed: {d}')" -``` - ---- - -## How to Talk About DVs - -**Do say:** -- "Your cluster has 4 operators with a 3-of-4 threshold, so it tolerates one operator going offline." -- "The cluster lock (identified by its `lock_hash`) is the source of truth after DKG." -- "Effectiveness of 0.95 means the validator is attesting in ~95% of expected slots." -- "Exit coordination requires threshold operators to submit their key shares of the exit message." - -**Avoid:** -- Saying the private key is "split into pieces" — it's threshold cryptography; no full key is ever assembled. -- Saying a cluster "fails" if one operator goes offline — it degrades gracefully until the threshold is not met. -- Confusing `config_hash` (pre-DKG) with `lock_hash` (post-DKG). - -## Identifier Formats - -- `lock_hash` and `config_hash`: hex strings starting with `0x`, typically 66 characters -- Operator `address`: standard Ethereum address, `0x` + 40 hex chars -- Validator `pubkey`: BLS public key, `0x` + 96 hex chars - -All identifiers are **case-sensitive** in Obol API calls. If a user provides an address without `0x`, remind them to include it. - -**Networks:** `mainnet` (real ETH), `hoodi` (staking/infra testnet, successor to holesky), `holesky` (legacy testnet), `sepolia` (secondary testnet) - -**Validator status values:** `active_ongoing`, `active_exiting`, `active_slashed`, `exited_unslashed`, `exited_slashed`, `withdrawal_possible`, `withdrawal_done`, `pending_*` - -## Examples - -For parameter shapes, response field reference, and example conversation patterns, see: -`references/api-examples.md` - -## Limitations - -- All API calls are **read-only** — creating clusters, running DKG, and submitting exits require authenticated POST endpoints -- Exit status endpoints are under `/v1/exp/` (experimental) — pagination is 1-indexed -- API rate limits apply; if timeouts occur, check `GET /v1/_health` first -- mcporter MCP integration requires the obol-mcp server to be installed (pip not available in pod currently) diff --git a/internal/embed/skills/obol-k8s/SKILL.md b/internal/embed/skills/obol-k8s/SKILL.md deleted file mode 100644 index 5ec94ebf..00000000 --- a/internal/embed/skills/obol-k8s/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: obol-k8s -description: "Kubernetes cluster awareness via ServiceAccount API. Use when: checking pod status, reading logs, listing services, viewing events, diagnosing deployment issues, or inspecting resource health in own namespace. NOT for: cross-namespace operations, creating/modifying resources, network management (use obol-network), or DVT operations (use obol-dvt)." -metadata: { "openclaw": { "emoji": "☸️", "requires": { "bins": ["curl", "python3"] } } } ---- - -# Obol K8s - -Monitor your Kubernetes environment using the mounted ServiceAccount token. Read-only access to pods, logs, services, events, and more within your own namespace. - -## When to Use - -- "What pods are running?" -- "Show me the logs for the openclaw pod" -- "Are there any warning events?" -- "What services are available?" -- "Why is a pod crashing?" -- "How many replicas are ready?" -- Diagnosing deployment issues (restarts, OOMKill, image pull errors) - -## When NOT to Use - -- Cross-namespace operations — SA is scoped to own namespace only -- Creating or modifying resources — read-only access -- Network deployment management — use the obol CLI -- Blockchain queries — use `obol-blockchain` -- DVT cluster monitoring — use `obol-dvt` - -## Scope - -**Read-only access to own namespace only.** The ServiceAccount has `get`, `list`, `watch` permissions on: - -| Resource | API Group | -|----------|-----------| -| Pods | core | -| Pods/log | core | -| Services | core | -| ConfigMaps | core | -| Events | core | -| PersistentVolumeClaims | core | -| Deployments | apps | -| ReplicaSets | apps | -| StatefulSets | apps | -| Jobs | batch | -| CronJobs | batch | - -**Cannot:** list namespaces, read other namespaces, create/update/delete resources. - -## Quick Start - -```bash -# List all pods with status -python3 scripts/kube.py pods - -# Get logs from a pod -python3 scripts/kube.py logs openclaw-7f8b9c6d5-x2k4j - -# Recent warning events -python3 scripts/kube.py events --type Warning - -# List services -python3 scripts/kube.py services - -# Deployment status -python3 scripts/kube.py deployments - -# Full details of a resource -python3 scripts/kube.py describe pod openclaw-7f8b9c6d5-x2k4j -``` - -## Direct curl - -The SA token and CA cert are mounted in the pod. You can query the Kubernetes API directly: - -```bash -# Setup variables -TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) -NS=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace) -API="https://kubernetes.default.svc" -CA="--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - -# List pods -curl -s $CA -H "Authorization: Bearer $TOKEN" \ - "$API/api/v1/namespaces/$NS/pods" | python3 -c " -import sys,json -pods = json.load(sys.stdin)['items'] -for p in pods: - name = p['metadata']['name'] - phase = p['status']['phase'] - restarts = sum(c.get('restartCount',0) for c in p['status'].get('containerStatuses',[])) - print(f'{name} {phase} restarts={restarts}') -" - -# Get pod logs (last 50 lines) -curl -s $CA -H "Authorization: Bearer $TOKEN" \ - "$API/api/v1/namespaces/$NS/pods//log?tailLines=50" - -# List events (warnings only) -curl -s $CA -H "Authorization: Bearer $TOKEN" \ - "$API/api/v1/namespaces/$NS/events?fieldSelector=type=Warning" -``` - -## Interpreting Pod Status - -| Phase | Meaning | -|-------|---------| -| `Running` | Pod is executing normally | -| `Pending` | Waiting to be scheduled (check events for reason) | -| `Succeeded` | All containers exited successfully (common for Jobs) | -| `Failed` | All containers terminated, at least one failed | -| `Unknown` | Pod state cannot be determined | - -### Container States - -| State | Common Cause | -|-------|-------------| -| `Waiting: CrashLoopBackOff` | Container crashes repeatedly. Check logs. | -| `Waiting: ImagePullBackOff` | Cannot pull container image. Check image name/tag. | -| `Waiting: ContainerCreating` | Pulling image or mounting volumes. | -| `Terminated: OOMKilled` | Out of memory. Pod needs higher memory limits. | -| `Terminated: Error` | Container exited with non-zero code. Check logs. | - -## Troubleshooting Patterns - -### Pod Won't Start - -1. `python3 scripts/kube.py pods` — check status -2. `python3 scripts/kube.py events --type Warning` — look for scheduling or image errors -3. `python3 scripts/kube.py describe pod ` — check conditions and container state - -### Pod Keeps Restarting - -1. `python3 scripts/kube.py pods` — check restart count -2. `python3 scripts/kube.py logs ` — check last log output before crash -3. Look for OOMKilled in container status — if so, memory limit too low - -### Service Not Reachable - -1. `python3 scripts/kube.py services` — verify service exists and ports -2. `python3 scripts/kube.py pods` — verify backing pods are Running -3. `python3 scripts/kube.py describe service ` — check endpoints - -## Constraints - -- **Read-only** — cannot create, modify, or delete any resources -- **Own namespace only** — cannot see other namespaces or cluster-level resources -- **No kubectl** — uses curl + SA token (kubectl binary not installed in pod) -- **Formatted output** — the helper script outputs human-readable text, not raw JSON diff --git a/internal/embed/skills/obol-stack/SKILL.md b/internal/embed/skills/obol-stack/SKILL.md new file mode 100644 index 00000000..e0d7e071 --- /dev/null +++ b/internal/embed/skills/obol-stack/SKILL.md @@ -0,0 +1,96 @@ +--- +name: obol-stack +description: "Monitor the Kubernetes cluster running the Obol Stack. Use when asked about pod status, logs, services, events, deployments, or diagnosing issues. Read-only access to own namespace via ServiceAccount." +metadata: { "openclaw": { "emoji": "☸️", "requires": { "bins": ["curl", "python3"] } } } +--- + +# Obol Stack + +Monitor the Kubernetes environment running the Obol Stack. Check pod status, read logs, list services, view events, and diagnose issues. + +## When to Use + +- Checking what pods are running and their status +- Reading pod logs +- Listing services and their ports +- Viewing warning events +- Diagnosing crashes, restarts, or scheduling issues + +## When NOT to Use + +- Cross-namespace operations (scoped to own namespace only) +- Creating or modifying resources (read-only) +- Ethereum RPC queries — use `ethereum-networks` +- Validator monitoring — use `distributed-validators` + +## Quick Start + +```bash +# List pods with status +python3 scripts/kube.py pods + +# Get pod logs +python3 scripts/kube.py logs + +# Recent warning events +python3 scripts/kube.py events --type Warning + +# List services +python3 scripts/kube.py services + +# Deployment status +python3 scripts/kube.py deployments + +# Full details of a resource +python3 scripts/kube.py describe pod +``` + +## Available Commands + +| Command | What it shows | +|---------|--------------| +| `pods` | All pods with status, restarts, and age | +| `logs [--tail N]` | Pod logs (default 100 lines) | +| `events [--type Warning]` | Namespace events, optionally filtered | +| `services` | Services with type, IP, and ports | +| `deployments` | Deployments with ready/desired replica counts | +| `configmaps` | ConfigMaps with key counts | +| `describe ` | Full JSON detail for any resource | + +Supported types for `describe`: pod, service, deployment, configmap, event, pvc, statefulset, job, cronjob, replicaset. + +## Troubleshooting + +### Pod won't start + +1. `python3 scripts/kube.py pods` — check status +2. `python3 scripts/kube.py events --type Warning` — look for errors +3. `python3 scripts/kube.py describe pod ` — check conditions + +### Pod keeps restarting + +1. Check restart count with `pods` +2. Read logs with `logs ` — look at output before the crash +3. If status shows `OOMKilled`, the pod needs more memory + +### Service not reachable + +1. `services` — verify it exists and check ports +2. `pods` — verify backing pods are Running +3. `describe service ` — check endpoints + +## Pod Status Reference + +| Status | Meaning | +|--------|---------| +| `Running` | Normal operation | +| `Pending` | Waiting to be scheduled | +| `CrashLoopBackOff` | Crashing repeatedly — check logs | +| `ImagePullBackOff` | Can't pull container image | +| `OOMKilled` | Out of memory | + +## Constraints + +- **Read-only** — cannot create, modify, or delete resources +- **Own namespace only** — cannot see other namespaces +- **No kubectl** — uses the Kubernetes API directly via curl diff --git a/internal/embed/skills/obol-k8s/scripts/kube.py b/internal/embed/skills/obol-stack/scripts/kube.py similarity index 100% rename from internal/embed/skills/obol-k8s/scripts/kube.py rename to internal/embed/skills/obol-stack/scripts/kube.py diff --git a/internal/openclaw/integration_test.go b/internal/openclaw/integration_test.go index 305d4fb6..65680942 100644 --- a/internal/openclaw/integration_test.go +++ b/internal/openclaw/integration_test.go @@ -600,7 +600,7 @@ func TestIntegration_SkillsStagedOnSync(t *testing.T) { // 1. Verify skills were staged in the deployment directory deployDir := deploymentPath(cfg, id) skillsDir := filepath.Join(deployDir, "skills") - expectedSkills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + expectedSkills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} for _, skill := range expectedSkills { skillMD := filepath.Join(skillsDir, skill, "SKILL.md") @@ -617,9 +617,9 @@ func TestIntegration_SkillsStagedOnSync(t *testing.T) { // Verify scripts and references were also staged for _, sub := range []string{ - "obol-blockchain/scripts/rpc.py", - "obol-k8s/scripts/kube.py", - "obol-dvt/references/api-examples.md", + "ethereum-networks/scripts/rpc.py", + "obol-stack/scripts/kube.py", + "distributed-validators/references/api-examples.md", } { if _, err := os.Stat(filepath.Join(skillsDir, sub)); err != nil { t.Errorf("missing staged file %s: %v", sub, err) @@ -664,7 +664,7 @@ func TestIntegration_SkillsVisibleInPod(t *testing.T) { ) t.Logf("skills visible in pod:\n%s", output) - expectedSkills := []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} + expectedSkills := []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} for _, skill := range expectedSkills { if !strings.Contains(output, skill) { t.Errorf("skill %q not visible in pod; ls output:\n%s", skill, output) @@ -675,12 +675,12 @@ func TestIntegration_SkillsVisibleInPod(t *testing.T) { mdContent := obolRun(t, cfg, "kubectl", "exec", "-c", "openclaw", "-n", namespace, "deploy/openclaw", "--", - "head", "-5", "/data/.openclaw/skills/obol-blockchain/SKILL.md", + "head", "-5", "/data/.openclaw/skills/ethereum-networks/SKILL.md", ) - if !strings.Contains(mdContent, "obol-blockchain") && !strings.Contains(mdContent, "blockchain") { - t.Errorf("obol-blockchain SKILL.md not readable in pod; got:\n%s", mdContent) + if !strings.Contains(mdContent, "ethereum-networks") && !strings.Contains(mdContent, "Ethereum") { + t.Errorf("ethereum-networks SKILL.md not readable in pod; got:\n%s", mdContent) } - t.Logf("obol-blockchain SKILL.md header in pod:\n%s", mdContent) + t.Logf("ethereum-networks SKILL.md header in pod:\n%s", mdContent) } // TestIntegration_SkillsSync verifies that `obol openclaw skills sync --from` @@ -778,7 +778,7 @@ func TestIntegration_SkillsIdempotentSync(t *testing.T) { } // Embedded skills should also still be present (from first sync) - for _, skill := range []string{"hello", "obol-blockchain"} { + for _, skill := range []string{"ethereum-networks", "distributed-validators"} { skillMD := filepath.Join(deployDir, "skills", skill, "SKILL.md") if _, err := os.Stat(skillMD); err != nil { t.Errorf("embedded skill %q removed after re-sync: %v", skill, err) @@ -828,16 +828,16 @@ func TestIntegration_SkillInference(t *testing.T) { // The agent must mention at least 2 of our 4 embedded skills. // We check for partial matches to be resilient to model output variations - // (e.g., "obol-blockchain" vs "obol blockchain" vs "Obol Blockchain"). + // (e.g., "ethereum-networks" vs "ethereum networks" vs "Ethereum Networks"). skillHits := 0 skillChecks := []struct { name string patterns []string }{ - {"hello", []string{"hello"}}, - {"obol-blockchain", []string{"blockchain", "obol-blockchain"}}, - {"obol-k8s", []string{"obol-k8s", "kubernetes", "k8s"}}, - {"obol-dvt", []string{"obol-dvt", "dvt", "distributed validator"}}, + {"ethereum-networks", []string{"ethereum-networks", "ethereum networks", "blockchain"}}, + {"distributed-validators", []string{"distributed-validators", "distributed validator", "dvt"}}, + {"obol-stack", []string{"obol-stack", "obol stack", "kubernetes", "k8s"}}, + {"ethereum-wallet", []string{"ethereum-wallet", "ethereum wallet", "wallet"}}, } for _, sc := range skillChecks { diff --git a/internal/openclaw/skills_injection_test.go b/internal/openclaw/skills_injection_test.go index 28f2378e..02e31b08 100644 --- a/internal/openclaw/skills_injection_test.go +++ b/internal/openclaw/skills_injection_test.go @@ -29,7 +29,7 @@ func TestStageDefaultSkills(t *testing.T) { } // Verify all expected skills were staged - for _, skill := range []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} { skillMD := filepath.Join(skillsDir, skill, "SKILL.md") if _, err := os.Stat(skillMD); err != nil { t.Errorf("%s/SKILL.md not staged: %v", skill, err) @@ -59,7 +59,7 @@ func TestStageDefaultSkillsSkipsExisting(t *testing.T) { } // And no embedded skills should have been written (directory was pre-existing) - if _, err := os.Stat(filepath.Join(skillsDir, "hello", "SKILL.md")); err == nil { + if _, err := os.Stat(filepath.Join(skillsDir, "ethereum-networks", "SKILL.md")); err == nil { t.Errorf("embedded skills should NOT have been staged into existing directory") } } @@ -77,7 +77,7 @@ func TestInjectSkillsToVolume(t *testing.T) { // Verify skills landed in the volume path volumePath := skillsVolumePath(cfg, "test-inject") - for _, skill := range []string{"hello", "obol-blockchain", "obol-dvt", "obol-k8s"} { + for _, skill := range []string{"distributed-validators", "ethereum-networks", "ethereum-wallet", "obol-stack"} { skillMD := filepath.Join(volumePath, skill, "SKILL.md") if _, err := os.Stat(skillMD); err != nil { t.Errorf("%s/SKILL.md not injected to volume: %v", skill, err) @@ -86,9 +86,9 @@ func TestInjectSkillsToVolume(t *testing.T) { // Verify scripts and references are also injected for _, sub := range []string{ - "obol-blockchain/scripts/rpc.py", - "obol-k8s/scripts/kube.py", - "obol-dvt/references/api-examples.md", + "ethereum-networks/scripts/rpc.py", + "obol-stack/scripts/kube.py", + "distributed-validators/references/api-examples.md", } { path := filepath.Join(volumePath, sub) if _, err := os.Stat(path); err != nil { From a07631e73dd8b00a707da717b3e6eeef28f0260f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 20 Feb 2026 11:58:28 +0000 Subject: [PATCH 8/8] Polish --- .gitignore | 4 ++ .../skills/distributed-validators/SKILL.md | 38 ++++++++++++++----- .../embed/skills/ethereum-networks/SKILL.md | 18 +++++++-- .../skills/ethereum-networks/scripts/rpc.py | 22 ++++++++++- internal/embed/skills/obol-stack/SKILL.md | 2 + 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 1c9a7def..fe4d1f12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,10 @@ build/ !.env.example .envrc.local +# Python +__pycache__/ +*.pyc + # Temporary files *.tmp tmp/ diff --git a/internal/embed/skills/distributed-validators/SKILL.md b/internal/embed/skills/distributed-validators/SKILL.md index c839279c..444140b1 100644 --- a/internal/embed/skills/distributed-validators/SKILL.md +++ b/internal/embed/skills/distributed-validators/SKILL.md @@ -104,26 +104,41 @@ Networks: `mainnet`, `hoodi`, `holesky`, `sepolia` ```bash # 1. Get cluster info curl -s "https://api.obol.tech/v1/lock/0x..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -print(f'Cluster: {d.get(\"name\",\"?\")} | {d.get(\"threshold\",\"?\")}-of-{len(d.get(\"operators\",[]))} | {d.get(\"network\",\"?\")}')" +import sys, json +d = json.load(sys.stdin) +if 'error' in d or 'name' not in d: + print('Error or cluster not found:', json.dumps(d, indent=2)) +else: + ops = d.get('operators', []) + print(f'Cluster: {d.get(\"name\",\"?\")} | {d.get(\"threshold\",\"?\")}-of-{len(ops)} | {d.get(\"network\",\"?\")}') +" # 2. Check validator effectiveness curl -s "https://api.obol.tech/v1/effectiveness/0x..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -for v in d.get('effectiveness',[]): - eff = v.get('effectiveness',0) +import sys, json +d = json.load(sys.stdin) +for v in d.get('effectiveness', []): + eff = v.get('effectiveness', 0) status = 'healthy' if eff > 0.95 else 'degraded' if eff > 0.8 else 'CRITICAL' - print(f'{v.get(\"public_key\",\"?\")[:16]}... {eff:.3f} [{status}]')" + print(f'{v.get(\"public_key\", \"?\")[:16]}... {eff:.3f} [{status}]') +if not d.get('effectiveness'): + print('No effectiveness data found') +" ``` ### Check exit progress ```bash curl -s "https://api.obol.tech/v1/exp/exit/status/summary/0x..." | python3 -c " -import sys,json; d=json.load(sys.stdin) -print(f'Ready to exit: {d.get(\"validators_ready_to_exit\",0)}/{d.get(\"total_validators\",0)}') -for op in d.get('operators',[]): - print(f' {op[\"address\"][:12]}... signed: {op.get(\"signed_exits\",0)}')" +import sys, json +d = json.load(sys.stdin) +if not isinstance(d, dict) or 'total_validators' not in d: + print('No exit data or cluster not found:', json.dumps(d, indent=2)) +else: + print(f'Ready to exit: {d.get(\"validators_ready_to_exit\", 0)}/{d.get(\"total_validators\", 0)}') + for op in d.get('operators', []): + print(f' {op.get(\"address\", \"?\")[:12]}... signed: {op.get(\"signed_exits\", 0)}') +" ``` Exit broadcasts automatically once enough operators have signed (threshold reached). @@ -133,5 +148,8 @@ Exit broadcasts automatically once enough operators have signed (threshold reach - **Read-only** — creating clusters, running DKG, and submitting exits require authenticated endpoints - Exit status endpoints (`/v1/exp/`) are experimental — pagination is 1-indexed - If timeouts occur, check `GET /v1/_health` first +- **Shell is `sh`, not `bash`** — do not use bashisms like `${var//pattern}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only +- **Python stdlib only** — only the Python 3.11 standard library is available. No third-party packages +- **Always check for null/missing data** — API responses may return errors or empty results. Always check before accessing nested fields See `references/api-examples.md` for response shapes and field reference. diff --git a/internal/embed/skills/ethereum-networks/SKILL.md b/internal/embed/skills/ethereum-networks/SKILL.md index 02ee6753..ae4227ae 100644 --- a/internal/embed/skills/ethereum-networks/SKILL.md +++ b/internal/embed/skills/ethereum-networks/SKILL.md @@ -99,14 +99,26 @@ See `references/erc20-methods.md` for the full selector reference and `reference When the helper script doesn't cover a method: ```bash -curl -s -X POST "$ERPC_URL/mainnet" \ +curl -s -X POST http://erpc.erpc.svc.cluster.local:4000/rpc/mainnet \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ - | python3 -c "import sys,json; r=json.load(sys.stdin); print(int(r['result'],16) if 'result' in r else r)" + | python3 -c " +import sys, json +r = json.load(sys.stdin) +if r.get('result') is not None: + print(int(r['result'], 16)) +elif 'error' in r: + print('Error:', r['error'].get('message', r['error'])) +else: + print(r) +" ``` ## Constraints - **Read-only** — no private keys, no signing, no state changes -- **Local routing** — always use eRPC (`$ERPC_URL`), never call external providers +- **Local routing** — always route through eRPC at `http://erpc.erpc.svc.cluster.local:4000/rpc/`, never call external RPC providers - **Hex encoding** — JSON-RPC uses hex; the helper script auto-converts common cases +- **Shell is `sh`, not `bash`** — do not use bashisms like `${var//pattern}`, `${var:offset}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only +- **Python stdlib only** — only the Python 3.11 standard library is available. Do not import `web3`, `eth_abi`, `rlp`, `pysha3`, or any third-party package +- **Always check for null results** — RPC methods like `eth_getTransactionByHash` return `null` for unknown hashes. Always check `if result is not None` before accessing fields diff --git a/internal/embed/skills/ethereum-networks/scripts/rpc.py b/internal/embed/skills/ethereum-networks/scripts/rpc.py index 3a55fcec..a8c03e6d 100644 --- a/internal/embed/skills/ethereum-networks/scripts/rpc.py +++ b/internal/embed/skills/ethereum-networks/scripts/rpc.py @@ -15,6 +15,7 @@ import json import os import sys +import urllib.error import urllib.request # eRPC requires /rpc/{network} path. ERPC_URL is the base (without network). @@ -43,8 +44,25 @@ def rpc_call(method, params=None, network=None): headers={"Content-Type": "application/json"}, ) - with urllib.request.urlopen(req, timeout=30) as resp: - data = json.loads(resp.read()) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + hints = { + 413: "Request too large — try a smaller block range or simpler query", + 502: "eRPC gateway not ready — is the network installed?", + 503: "eRPC gateway unavailable — check if the erpc pod is running", + } + hint = hints.get(e.code, "") + msg = f"HTTP {e.code}" + if hint: + msg += f": {hint}" + print(msg, file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"Connection failed: {e.reason}", file=sys.stderr) + print(f"Is the eRPC gateway reachable at {url}?", file=sys.stderr) + sys.exit(1) if "error" in data: code = data["error"].get("code", "?") diff --git a/internal/embed/skills/obol-stack/SKILL.md b/internal/embed/skills/obol-stack/SKILL.md index e0d7e071..315848bc 100644 --- a/internal/embed/skills/obol-stack/SKILL.md +++ b/internal/embed/skills/obol-stack/SKILL.md @@ -94,3 +94,5 @@ Supported types for `describe`: pod, service, deployment, configmap, event, pvc, - **Read-only** — cannot create, modify, or delete resources - **Own namespace only** — cannot see other namespaces - **No kubectl** — uses the Kubernetes API directly via curl +- **Shell is `sh`, not `bash`** — do not use bashisms like `${var//pattern}`, `[[ ]]`, or arrays. Use POSIX-compatible syntax only +- **Python stdlib only** — only the Python 3.11 standard library is available. No third-party packages