feat: config, OpenAI-compatible streaming provider and safe tools
This commit is contained in:
commit
c1c7b0e005
13 changed files with 1031 additions and 0 deletions
143
internal/config/config.go
Normal file
143
internal/config/config.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Package config loads OpenClaw configuration from YAML/JSON files and env vars.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type LLM struct {
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
TimeoutSec int `yaml:"timeout_sec" json:"timeout_sec"`
|
||||
Temperature float64 `yaml:"temperature" json:"temperature"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
HistoryLimit int `yaml:"history_limit" json:"history_limit"`
|
||||
}
|
||||
|
||||
type WebFetch struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
// MaxBytes caps the response body read from remote servers.
|
||||
MaxBytes int64 `yaml:"max_bytes" json:"max_bytes"`
|
||||
TimeoutSec int `yaml:"timeout_sec" json:"timeout_sec"`
|
||||
// AllowPrivate permits fetching loopback/private/link-local addresses (SSRF risk). Default false.
|
||||
AllowPrivate bool `yaml:"allow_private" json:"allow_private"`
|
||||
}
|
||||
|
||||
type Tools struct {
|
||||
WebFetch WebFetch `yaml:"web_fetch" json:"web_fetch"`
|
||||
// FilesEnabled enables read/write/list tools confined to the workspace directory.
|
||||
FilesEnabled bool `yaml:"files_enabled" json:"files_enabled"`
|
||||
// FileMaxBytes caps file reads/writes.
|
||||
FileMaxBytes int64 `yaml:"file_max_bytes" json:"file_max_bytes"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen" json:"listen"`
|
||||
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
||||
// AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
|
||||
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
|
||||
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||
Workspace string `yaml:"workspace" json:"workspace"`
|
||||
LLM LLM `yaml:"llm" json:"llm"`
|
||||
Agent Agent `yaml:"agent" json:"agent"`
|
||||
Tools Tools `yaml:"tools" json:"tools"`
|
||||
}
|
||||
|
||||
// Default returns a config with secure defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Listen: "127.0.0.1:8787",
|
||||
DataDir: "./data",
|
||||
LLM: LLM{
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
TimeoutSec: 120,
|
||||
Temperature: 0.7,
|
||||
},
|
||||
Agent: Agent{
|
||||
SystemPrompt: "You are OpenClaw, a concise and helpful self-hosted assistant. Use tools when they help.",
|
||||
MaxIterations: 8,
|
||||
HistoryLimit: 50,
|
||||
},
|
||||
Tools: Tools{
|
||||
WebFetch: WebFetch{Enabled: true, MaxBytes: 512 * 1024, TimeoutSec: 15},
|
||||
FilesEnabled: true,
|
||||
FileMaxBytes: 1 << 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads path (optional; .json or .yaml/.yml) on top of defaults, then applies env overrides.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
if path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".json":
|
||||
err = json.Unmarshal(b, &cfg)
|
||||
default:
|
||||
err = yaml.Unmarshal(b, &cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
}
|
||||
applyEnv(&cfg, os.Getenv)
|
||||
if cfg.Workspace == "" {
|
||||
cfg.Workspace = filepath.Join(cfg.DataDir, "workspace")
|
||||
}
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
func applyEnv(c *Config, get func(string) string) {
|
||||
set := func(key string, dst *string) {
|
||||
if v := get(key); v != "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
set("OPENCLAW_LISTEN", &c.Listen)
|
||||
set("OPENCLAW_AUTH_TOKEN", &c.AuthToken)
|
||||
set("OPENCLAW_DATA_DIR", &c.DataDir)
|
||||
set("OPENCLAW_WORKSPACE", &c.Workspace)
|
||||
set("OPENCLAW_LLM_BASE_URL", &c.LLM.BaseURL)
|
||||
set("OPENCLAW_LLM_API_KEY", &c.LLM.APIKey)
|
||||
set("OPENCLAW_LLM_MODEL", &c.LLM.Model)
|
||||
if v := get("OPENCLAW_ALLOWED_ORIGINS"); v != "" {
|
||||
c.AllowedOrigins = nil
|
||||
for _, o := range strings.Split(v, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate enforces required fields and secure settings.
|
||||
func (c Config) Validate() error {
|
||||
var errs []error
|
||||
if len(c.AuthToken) < 16 {
|
||||
errs = append(errs, errors.New("auth_token must be set and at least 16 characters (OPENCLAW_AUTH_TOKEN)"))
|
||||
}
|
||||
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
||||
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
|
||||
}
|
||||
if c.Agent.MaxIterations <= 0 {
|
||||
errs = append(errs, errors.New("agent.max_iterations must be > 0"))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
44
internal/config/config_test.go
Normal file
44
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadYAMLAndEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "c.yaml")
|
||||
os.WriteFile(p, []byte("auth_token: aaaaaaaaaaaaaaaaaaaa\nllm:\n model: llama3\n"), 0o600)
|
||||
t.Setenv("OPENCLAW_LLM_BASE_URL", "http://localhost:11434/v1")
|
||||
t.Setenv("OPENCLAW_ALLOWED_ORIGINS", "https://a.example, https://b.example")
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.LLM.Model != "llama3" || c.LLM.BaseURL != "http://localhost:11434/v1" {
|
||||
t.Fatalf("unexpected llm cfg %+v", c.LLM)
|
||||
}
|
||||
if len(c.AllowedOrigins) != 2 || c.AllowedOrigins[1] != "https://b.example" {
|
||||
t.Fatalf("origins %v", c.AllowedOrigins)
|
||||
}
|
||||
if c.Listen != "127.0.0.1:8787" {
|
||||
t.Fatalf("default listen changed: %s", c.Listen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadJSON(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "c.json")
|
||||
os.WriteFile(p, []byte(`{"auth_token":"bbbbbbbbbbbbbbbbbbbb","listen":":9000"}`), 0o600)
|
||||
c, err := Load(p)
|
||||
if err != nil || c.Listen != ":9000" {
|
||||
t.Fatalf("%v %v", c.Listen, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresToken(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_AUTH_TOKEN", "")
|
||||
if _, err := Load(""); err == nil {
|
||||
t.Fatal("expected error without token")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue