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])
}
|