Download
391 lines 12.5 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
// Package config reads and writes the settings, JSON files in blinker's
// XDG config directory: the sandbox's in config.json, which blinker and
// blinker-agent share, and the agent's in agent.json. It also knows where
// blinker keeps its files.
package config

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"net/url"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"
	"unicode"
)

// Duration is a time.Duration written in JSON as a string such as "2m".
type Duration time.Duration

func (d Duration) MarshalJSON() ([]byte, error) {
	return json.Marshal(time.Duration(d).String())
}

func (d *Duration) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return fmt.Errorf("durations are strings such as \"2m\": %w", err)
	}
	v, err := time.ParseDuration(s)
	if err != nil {
		return err
	}
	*d = Duration(v)
	return nil
}

// The settings files, in the config directory.
const (
	SandboxFile = "config.json"
	AgentFile   = "agent.json"
)

// Sandbox is the sandbox's settings.
type Sandbox struct {
	ProxyPorts         []int    `json:"proxy_ports"`          // ports sandboxed commands may reach
	AllowPrivateEgress bool     `json:"allow_private_egress"` // let the proxy reach private addresses
	ExtraBlocklist     []string `json:"extra_blocklist,omitempty"`
	ExtraNeverWrite    []string `json:"extra_never_write,omitempty"`
	ScratchRoot        string   `json:"scratch_root,omitempty"` // "" for the temp dir
}

// Config is every setting of blinker-agent's: its own, and the sandbox's.
type Config struct {
	// The LLM backend, chosen in setup.
	BaseURL        string   `json:"base_url"`
	APIKey         string   `json:"api_key,omitempty"` // "" falls back to $OPENAI_API_KEY ($ANTHROPIC_API_KEY for api.anthropic.com)
	Model          string   `json:"model"`
	CACert         string   `json:"ca_cert,omitempty"` // a PEM certificate to trust for the API
	Thinking       bool     `json:"thinking"`
	ThinkingParam  string   `json:"thinking_param"` // "chat_template_kwargs" or "none"
	Sampling       bool     `json:"sampling"`       // send temperature, top_p and top_k
	Temperature    float64  `json:"temperature"`
	TopP           float64  `json:"top_p"`
	TopK           int      `json:"top_k"`
	MaxTokens      int      `json:"max_tokens"`               // cap on each reply, reasoning included; 0 for none
	MaxTokensParam string   `json:"max_tokens_param"`         // "max_tokens" or "max_completion_tokens"
	ContextWindow  int      `json:"context_window,omitempty"` // the model's context size in tokens; 0 to ask the server
	RequestTimeout Duration `json:"request_timeout"`

	CommandTimeout Duration `json:"command_timeout"` // default limit for the bash tools

	// DiffCommand, if set, is a program (and its arguments) that renders
	// diffs for display, such as ["delta"]: it reads a unified diff on
	// stdin and writes it, colored, to stdout. An argument "{width}" is
	// replaced with the terminal width.
	DiffCommand []string `json:"diff_command,omitempty"`

	// LogFile, if set, receives a log of API traffic (with the key redacted).
	LogFile string `json:"log_file,omitempty"`

	// Sandbox is kept in its own file.
	Sandbox Sandbox `json:"-"`
}

// DefaultSandbox is the sandbox settings a new config starts with.
func DefaultSandbox() Sandbox {
	return Sandbox{ProxyPorts: []int{443, 80}}
}

// Validate checks the sandbox settings.
func (s Sandbox) Validate() error {
	var errs []error
	for _, p := range s.ProxyPorts {
		if p <= 0 || p > 65535 {
			errs = append(errs, fmt.Errorf("proxy_ports: invalid port %d", p))
		}
	}
	for _, p := range slices.Concat(s.ExtraBlocklist, s.ExtraNeverWrite) {
		if !strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "~/") {
			errs = append(errs, fmt.Errorf("floor path %q must be absolute or start with ~/", p))
		}
	}
	if s.ScratchRoot != "" && !filepath.IsAbs(s.ScratchRoot) {
		errs = append(errs, errors.New("scratch_root must be absolute"))
	}
	return errors.Join(errs...)
}

// Defaults are the settings a new config starts with.
func Defaults() Config {
	return Config{
		BaseURL:        "http://localhost:8080/v1",
		Thinking:       true,
		ThinkingParam:  "chat_template_kwargs",
		Sampling:       true,
		Temperature:    0.6,
		TopP:           0.95,
		TopK:           20,
		MaxTokens:      32768,
		MaxTokensParam: "max_tokens",
		RequestTimeout: Duration(10 * time.Minute),
		CommandTimeout: Duration(2 * time.Minute),
		Sandbox:        DefaultSandbox(),
	}
}

// Validate checks the settings, the sandbox's too.
func (c Config) Validate() error {
	errs := []error{c.Sandbox.Validate()}
	if c.BaseURL != "" {
		if u, err := url.Parse(c.BaseURL); err != nil || u.Scheme != "http" && u.Scheme != "https" || u.Host == "" {
			errs = append(errs, errors.New("base_url must be an http:// or https:// URL"))
		}
	}
	// The model's name may come from the server's list, and is drawn on
	// screen and passed to commands: it must not carry terminal controls.
	if strings.ContainsFunc(c.Model, func(r rune) bool { return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) }) {
		errs = append(errs, errors.New("model must not contain control characters"))
	}
	if c.Temperature < 0 || c.Temperature > 2 {
		errs = append(errs, errors.New("temperature must be from 0 to 2"))
	}
	if c.TopP < 0 || c.TopP > 1 {
		errs = append(errs, errors.New("top_p must be from 0 to 1"))
	}
	if c.TopK < 0 || c.MaxTokens < 0 {
		errs = append(errs, errors.New("top_k and max_tokens must not be negative"))
	}
	if c.CACert != "" && !filepath.IsAbs(c.CACert) {
		errs = append(errs, errors.New("ca_cert must be an absolute path"))
	}
	if c.LogFile != "" && !filepath.IsAbs(c.LogFile) {
		errs = append(errs, errors.New("log_file must be an absolute path"))
	}
	if c.ThinkingParam != "chat_template_kwargs" && c.ThinkingParam != "none" {
		errs = append(errs, errors.New(`thinking_param must be "chat_template_kwargs" or "none"`))
	}
	if c.MaxTokensParam != "max_tokens" && c.MaxTokensParam != "max_completion_tokens" {
		errs = append(errs, errors.New(`max_tokens_param must be "max_tokens" or "max_completion_tokens"`))
	}
	if c.ContextWindow < 0 {
		errs = append(errs, errors.New("context_window must not be negative"))
	}
	if c.RequestTimeout <= 0 || c.CommandTimeout <= 0 {
		errs = append(errs, errors.New("request_timeout and command_timeout must be positive"))
	}
	if len(c.DiffCommand) > 0 && c.DiffCommand[0] == "" {
		errs = append(errs, errors.New("diff_command must start with a program"))
	}
	return errors.Join(errs...)
}

// Configured reports whether setup has chosen a backend.
func (c Config) Configured() bool { return c.Model != "" && c.BaseURL != "" }

// Key returns the API key to use: the configured one, or else the
// provider's environment variable.
func (c Config) Key() string {
	if c.APIKey != "" {
		return c.APIKey
	}
	// Anthropic's key goes to Anthropic only: the host itself, over https,
	// not any URL that mentions it (https://api.anthropic.com.evil.example).
	if u, err := url.Parse(c.BaseURL); err == nil && u.Scheme == "https" && u.Hostname() == "api.anthropic.com" {
		if k := os.Getenv("ANTHROPIC_API_KEY"); k != "" {
			return k
		}
	}
	return os.Getenv("OPENAI_API_KEY")
}

// decode reads a settings file into v, which holds the defaults. A
// missing file leaves them. Unknown settings are an error, so a typo is
// not silently ignored.
func decode(path string, v any) error {
	data, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	dec := json.NewDecoder(bytes.NewReader(data))
	dec.DisallowUnknownFields()
	if err := dec.Decode(v); err != nil {
		return fmt.Errorf("%s: %w", path, err)
	}
	return nil
}

// LoadSandbox reads the sandbox settings from the config directory.
func LoadSandbox(dir string) (Sandbox, error) {
	s := DefaultSandbox()
	path := filepath.Join(dir, SandboxFile)
	if err := decode(path, &s); err != nil {
		return s, err
	}
	if err := s.Validate(); err != nil {
		return s, fmt.Errorf("%s: %w", path, err)
	}
	return s, nil
}

// Load reads every setting from the config directory.
func Load(dir string) (Config, error) {
	c := Defaults()
	path := filepath.Join(dir, AgentFile)
	if err := decode(path, &c); err != nil {
		return c, err
	}
	var err error
	if c.Sandbox, err = LoadSandbox(dir); err != nil {
		return c, err
	}
	if err := c.Validate(); err != nil {
		return c, fmt.Errorf("%s: %w", path, err)
	}
	// A key others can read is a key others have: refuse it, as ssh does
	// a private key. Blinker writes the file 0600.
	if c.APIKey == "" {
		return c, nil
	}
	fi, err := os.Stat(path)
	if err != nil {
		return c, err
	}
	if fi.Mode().Perm()&0o077 != 0 {
		return c, fmt.Errorf("%s holds an API key, but others can read it; run chmod 600 %s", path, path)
	}
	return c, nil
}

// Save writes the settings to the config directory, readable only by the
// user since they may hold an API key.
func (c Config) Save(dir string) error {
	data, err := json.MarshalIndent(c.Sandbox, "", "  ")
	if err != nil {
		return err
	}
	if err := WriteFile(filepath.Join(dir, SandboxFile), append(data, '\n'), 0o600); err != nil {
		return err
	}
	if data, err = json.MarshalIndent(c, "", "  "); err != nil {
		return err
	}
	return WriteFile(filepath.Join(dir, AgentFile), append(data, '\n'), 0o600)
}

// WriteFile writes a file atomically, through a temporary file in the same
// directory, creating the directory (private to the user) if needed.
func WriteFile(path string, data []byte, perm fs.FileMode) error {
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
	if err != nil {
		return err
	}
	name := tmp.Name()
	defer os.Remove(name) // fails harmlessly once renamed
	if err := tmp.Chmod(perm); err != nil {
		tmp.Close()
		return err
	}
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(name, path)
}

// xdg returns $<env>/blinker, or ~/<fallback>/blinker when the variable
// is unset or not absolute, as the XDG spec says, on macOS too.
func xdg(env, fallback string) (string, error) {
	if dir := os.Getenv(env); filepath.IsAbs(dir) {
		return filepath.Join(dir, "blinker"), nil
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, fallback, "blinker"), nil
}

// Dir is blinker's config directory, holding the settings, policies and
// tools.
func Dir() (string, error) { return xdg("XDG_CONFIG_HOME", ".config") }

// DataDirs are where blinker looks for the files it is installed with,
// such as policies: $XDG_DATA_HOME/blinker, then each dir in
// $XDG_DATA_DIRS (by default /usr/local/share and /usr/share) with
// /blinker added. Relative entries are skipped, as the XDG spec says.
func DataDirs() ([]string, error) {
	home, err := xdg("XDG_DATA_HOME", filepath.Join(".local", "share"))
	if err != nil {
		return nil, err
	}
	dirs := []string{home}
	list := os.Getenv("XDG_DATA_DIRS")
	if list == "" {
		list = "/usr/local/share:/usr/share"
	}
	for _, d := range strings.Split(list, ":") {
		if filepath.IsAbs(d) {
			dirs = append(dirs, filepath.Join(d, "blinker"))
		}
	}
	return dirs, nil
}

// StateDir is where blinker keeps history.
func StateDir() (string, error) { return xdg("XDG_STATE_HOME", filepath.Join(".local", "state")) }

// ProjectName turns a project's path into a file name: its path from
// home (or the whole path, outside home), as its letters, digits, dots,
// dashes and underscores (the end of it, if long), with the rest as
// dashes, then a short hash of the whole path so different paths never
// share a name. Home itself is "home".
func ProjectName(project string) string {
	home, err := os.UserHomeDir()
	if err != nil {
		home = "" // no home to leave out: the name shows the whole path
	}
	return projectName(project, home)
}

func projectName(project, home string) string {
	shown := project
	if home != "" && home != "/" {
		if project == home {
			shown = "home"
		} else if rest, ok := strings.CutPrefix(project, home+"/"); ok {
			shown = rest
		}
	}
	var b strings.Builder
	dash := false
	for _, c := range shown {
		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-' || c == '_' {
			b.WriteRune(c)
			dash = false
		} else if !dash {
			b.WriteByte('-')
			dash = true
		}
	}
	flat := strings.Trim(b.String(), "-")
	if len(flat) > 80 {
		flat = flat[len(flat)-80:]
	}
	if flat == "" {
		flat = "root"
	}
	sum := sha256.Sum256([]byte(project))
	return flat + "-" + hex.EncodeToString(sum[:4])
}