f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
2495 lines
76 KiB
Go
2495 lines
76 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"maps"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/gofrs/flock"
|
|
"github.com/spf13/pflag"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
// TerminalConfig holds terminal launch preferences.
|
|
type TerminalConfig struct {
|
|
// Mode: "auto" (detect terminal), "custom" (use CustomBin),
|
|
// or "clipboard" (never launch, always copy).
|
|
Mode string `json:"mode" toml:"mode"`
|
|
// CustomBin is the terminal binary path (used when Mode == "custom").
|
|
CustomBin string `json:"custom_bin,omitempty" toml:"custom_bin"`
|
|
// CustomArgs is a template for terminal args. Use {cmd} as
|
|
// placeholder for the resume command (e.g. "-- bash -c {cmd}").
|
|
CustomArgs string `json:"custom_args,omitempty" toml:"custom_args"`
|
|
}
|
|
|
|
// ProxyConfig controls an optional managed reverse proxy.
|
|
type ProxyConfig struct {
|
|
// Mode enables a managed proxy implementation.
|
|
// Currently supported: "caddy".
|
|
Mode string `json:"mode,omitempty" toml:"mode"`
|
|
// Bin overrides the proxy executable path.
|
|
Bin string `json:"bin,omitempty" toml:"bin"`
|
|
// BindHost is the local interface/IP the proxy binds to.
|
|
BindHost string `json:"bind_host,omitempty" toml:"bind_host"`
|
|
// PublicPort is the external port exposed by the proxy.
|
|
PublicPort int `json:"public_port,omitempty" toml:"public_port"`
|
|
// TLSCert and TLSKey are used by managed HTTPS mode.
|
|
TLSCert string `json:"tls_cert,omitempty" toml:"tls_cert"`
|
|
TLSKey string `json:"tls_key,omitempty" toml:"tls_key"`
|
|
// AllowedSubnets restrict inbound clients to these CIDRs.
|
|
AllowedSubnets []string `json:"allowed_subnets,omitempty" toml:"allowed_subnets"`
|
|
}
|
|
|
|
// PGConfig holds PostgreSQL connection settings.
|
|
type PGConfig struct {
|
|
URL string `toml:"url" json:"url"`
|
|
Schema string `toml:"schema" json:"schema"`
|
|
MachineName string `toml:"machine_name" json:"machine_name"`
|
|
AllowInsecure bool `toml:"allow_insecure" json:"allow_insecure"`
|
|
Projects []string `toml:"projects" json:"projects,omitempty"`
|
|
ExcludeProjects []string `toml:"exclude_projects" json:"exclude_projects,omitempty"`
|
|
// PushVectors gates the vector phase of pg push. A pointer so an
|
|
// absent key defaults to enabled without a load-time default pass.
|
|
PushVectors *bool `toml:"push_vectors" json:"push_vectors,omitempty"`
|
|
}
|
|
|
|
// PushVectorsEnabled reports whether pg push should run its vector phase
|
|
// for this target; unset means enabled.
|
|
func (p PGConfig) PushVectorsEnabled() bool {
|
|
return p.PushVectors == nil || *p.PushVectors
|
|
}
|
|
|
|
type pgEnvOverrides struct {
|
|
URL string
|
|
Schema string
|
|
MachineName string
|
|
}
|
|
|
|
// ResolvedPGTarget is one PostgreSQL target after target selection,
|
|
// defaulting, and default-target env overrides are applied.
|
|
type ResolvedPGTarget struct {
|
|
Name string
|
|
Config PGConfig
|
|
IsDefault bool
|
|
}
|
|
|
|
var pgConfigKeys = map[string]struct{}{
|
|
"url": {},
|
|
"schema": {},
|
|
"machine_name": {},
|
|
"allow_insecure": {},
|
|
"projects": {},
|
|
"exclude_projects": {},
|
|
"push_vectors": {},
|
|
}
|
|
|
|
// DuckDBConfig holds DuckDB mirror and Quack connection settings.
|
|
type DuckDBConfig struct {
|
|
Path string `toml:"path" json:"path"`
|
|
URL string `toml:"url" json:"url"`
|
|
Token string `toml:"token" json:"token,omitempty"`
|
|
MachineName string `toml:"machine_name" json:"machine_name"`
|
|
AllowInsecure bool `toml:"allow_insecure" json:"allow_insecure"`
|
|
Projects []string `toml:"projects" json:"projects,omitempty"`
|
|
ExcludeProjects []string `toml:"exclude_projects" json:"exclude_projects,omitempty"`
|
|
}
|
|
|
|
// VectorConfig holds settings for the optional local semantic-search
|
|
// vector index (embeddings + vectors.db).
|
|
type VectorConfig struct {
|
|
Enabled bool `toml:"enabled" json:"enabled"`
|
|
DBPath string `toml:"db_path" json:"db_path,omitempty"`
|
|
// IncludeAutomated controls whether automated (e.g. roborev) sessions'
|
|
// messages are embedded into the vector index, mirroring the
|
|
// IncludeAutomated convention search already uses to exclude those
|
|
// sessions from results by default. Default false: automated sessions
|
|
// are excluded from embedding, since they otherwise dominate a large
|
|
// archive's index with content that search already hides by default.
|
|
// `embeddings build --include-automated` can override this for a
|
|
// one-off build; see that flag's help for the scheduled-build caveat.
|
|
IncludeAutomated bool `toml:"include_automated" json:"include_automated"`
|
|
Embeddings VectorEmbeddingsConfig `toml:"embeddings" json:"embeddings"`
|
|
Embed VectorEmbedConfig `toml:"embed" json:"embed"`
|
|
}
|
|
|
|
// VectorEmbeddingsConfig describes the embedding space — the model identity
|
|
// every server must share — and the named servers that can encode it.
|
|
//
|
|
// Model identity (model, dimension, request_dimensions, max_input_chars,
|
|
// input_suffix) is deliberately global rather than per-server: it joins the
|
|
// generation fingerprint, and query vectors are only comparable to stored
|
|
// document vectors from the same space. Servers differ only in transport and
|
|
// capacity, so a build run on any server produces vectors every other
|
|
// server's queries can search.
|
|
type VectorEmbeddingsConfig struct {
|
|
Model string `toml:"model" json:"model"`
|
|
Dimension int `toml:"dimension" json:"dimension"`
|
|
// RequestDimensions, when true, sends Dimension as the OpenAI-compatible
|
|
// "dimensions" request field — for documents at build time and queries at
|
|
// search time alike — asking the endpoint for Matryoshka-reduced vectors
|
|
// of exactly that length (e.g. Qwen3-Embedding through Ollama). Requires
|
|
// a model and endpoint that support dimension selection; when false (the
|
|
// default) the field is never sent and Dimension only validates response
|
|
// length. Part of the generation fingerprint: enabling it re-embeds the
|
|
// archive on the next build.
|
|
RequestDimensions bool `toml:"request_dimensions" json:"request_dimensions,omitempty"`
|
|
// MaxInputChars caps the rune length of each chunk sent for
|
|
// embedding. Default 8192.
|
|
MaxInputChars int `toml:"max_input_chars" json:"max_input_chars"`
|
|
// InputSuffix is appended verbatim to every text sent for embedding
|
|
// (documents and queries alike). Some models expect a terminator the
|
|
// serving layer does not add — e.g. Qwen3-Embedding under llama.cpp
|
|
// wants "<|endoftext|>" appended client-side. Changing it cuts a new
|
|
// vector generation. Default empty.
|
|
InputSuffix string `toml:"input_suffix" json:"input_suffix,omitempty"`
|
|
// DefaultServer names the server used for search-time query encoding
|
|
// and for builds that don't select one (scheduled builds, and
|
|
// `embeddings build` without --using). Optional when exactly one
|
|
// server is defined.
|
|
DefaultServer string `toml:"default_server" json:"default_server,omitempty"`
|
|
// Servers is the set of named OpenAI-compatible endpoints that serve
|
|
// Model, keyed by the name `embeddings build --using <name>` selects.
|
|
Servers map[string]VectorEmbeddingsServerConfig `toml:"servers" json:"servers"`
|
|
}
|
|
|
|
// VectorEmbeddingsServerConfig is one named embeddings server: transport
|
|
// and capacity settings only; the model identity lives on
|
|
// VectorEmbeddingsConfig.
|
|
type VectorEmbeddingsServerConfig struct {
|
|
Endpoint string `toml:"endpoint" json:"endpoint"`
|
|
// APIKeyEnv names the environment variable holding the API key.
|
|
// Empty means anonymous access.
|
|
APIKeyEnv string `toml:"api_key_env" json:"api_key_env,omitempty"`
|
|
// BatchSize is the number of inputs sent per HTTP call. Default 32.
|
|
BatchSize int `toml:"batch_size" json:"batch_size"`
|
|
// Concurrency is the number of documents embedded in parallel during a
|
|
// build against this server. Sequential requests leave a build
|
|
// round-trip-bound against remote endpoints, so the default is 4;
|
|
// servers that process one request at a time simply queue the extras.
|
|
Concurrency int `toml:"concurrency" json:"concurrency"`
|
|
// Timeout is a parseable duration string applied to each HTTP
|
|
// call. Default "30s".
|
|
Timeout string `toml:"timeout" json:"timeout"`
|
|
// MaxRetries is the maximum total attempts on 429/5xx/network errors
|
|
// (4xx fails fast); 0 means one attempt. Default 3.
|
|
MaxRetries int `toml:"max_retries" json:"max_retries"`
|
|
}
|
|
|
|
// ResolvedDefaultServer returns the server name used when no explicit
|
|
// choice is made: default_server when set, otherwise the only defined
|
|
// server, otherwise "".
|
|
func (c VectorEmbeddingsConfig) ResolvedDefaultServer() string {
|
|
if c.DefaultServer != "" {
|
|
return c.DefaultServer
|
|
}
|
|
if len(c.Servers) == 1 {
|
|
for name := range c.Servers {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Server resolves name to a defined embeddings server; "" means the
|
|
// default. The resolved name is returned alongside the server so callers
|
|
// can report which server a build actually used.
|
|
func (c VectorEmbeddingsConfig) Server(name string) (string, VectorEmbeddingsServerConfig, error) {
|
|
if name == "" {
|
|
name = c.ResolvedDefaultServer()
|
|
}
|
|
s, ok := c.Servers[name]
|
|
if !ok {
|
|
return "", VectorEmbeddingsServerConfig{}, fmt.Errorf(
|
|
"[vector.embeddings] no server named %q; define it under [vector.embeddings.servers.%s] (have: %s)",
|
|
name, name, strings.Join(sortedServerNames(c.Servers), ", "))
|
|
}
|
|
return name, s, nil
|
|
}
|
|
|
|
// normalizedEmbeddingsServers fills each named server's unset transport
|
|
// fields with their defaults (batch_size 32, concurrency 4, timeout "30s",
|
|
// max_retries 3). meta.IsDefined distinguishes "unset" (apply the default)
|
|
// from an explicit zero — an explicit max_retries = 0 disables retries, and
|
|
// an explicit zero batch_size/concurrency stays zero so validation rejects
|
|
// it instead of silently substituting the default.
|
|
func normalizedEmbeddingsServers(
|
|
servers map[string]VectorEmbeddingsServerConfig, meta toml.MetaData,
|
|
) map[string]VectorEmbeddingsServerConfig {
|
|
out := make(map[string]VectorEmbeddingsServerConfig, len(servers))
|
|
for name, s := range servers {
|
|
if !meta.IsDefined("vector", "embeddings", "servers", name, "batch_size") {
|
|
s.BatchSize = 32
|
|
}
|
|
if !meta.IsDefined("vector", "embeddings", "servers", name, "concurrency") {
|
|
s.Concurrency = 4
|
|
}
|
|
if s.Timeout == "" {
|
|
s.Timeout = "30s"
|
|
}
|
|
if !meta.IsDefined("vector", "embeddings", "servers", name, "max_retries") {
|
|
s.MaxRetries = 3
|
|
}
|
|
out[name] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sortedServerNames returns the configured server names in sorted order,
|
|
// for deterministic error messages and validation.
|
|
func sortedServerNames(servers map[string]VectorEmbeddingsServerConfig) []string {
|
|
names := make([]string, 0, len(servers))
|
|
for name := range servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// VectorEmbedConfig configures when the daemon runs embedding work.
|
|
type VectorEmbedConfig struct {
|
|
// RunAfterSync enables debounced embedding of sync deltas.
|
|
// Defaults to true when unset; read it via RunAfterSyncEnabled.
|
|
RunAfterSync *bool `toml:"run_after_sync" json:"run_after_sync,omitempty"`
|
|
// BackstopInterval is a parseable duration string for a periodic
|
|
// full rescan. Default "24h"; a negative duration disables it.
|
|
BackstopInterval string `toml:"backstop_interval" json:"backstop_interval"`
|
|
}
|
|
|
|
// Validate checks the vector config for internal consistency. It is a
|
|
// no-op when the section is disabled.
|
|
func (c VectorConfig) Validate() error {
|
|
if !c.Enabled {
|
|
return nil
|
|
}
|
|
if c.Embeddings.Model == "" {
|
|
return fmt.Errorf("[vector.embeddings] model is required when [vector] is enabled")
|
|
}
|
|
if c.Embeddings.Dimension <= 0 {
|
|
return fmt.Errorf("[vector.embeddings] dimension must be greater than 0 when [vector] is enabled")
|
|
}
|
|
if c.Embeddings.MaxInputChars <= 0 {
|
|
return fmt.Errorf(
|
|
"[vector.embeddings] max_input_chars must be greater than 0, got %d",
|
|
c.Embeddings.MaxInputChars)
|
|
}
|
|
if err := c.Embeddings.validateServers(); err != nil {
|
|
return err
|
|
}
|
|
backstop, err := time.ParseDuration(c.Embed.BackstopInterval)
|
|
if err != nil {
|
|
return fmt.Errorf("[vector.embed] invalid backstop_interval %q: %w", c.Embed.BackstopInterval, err)
|
|
}
|
|
if backstop == 0 {
|
|
return fmt.Errorf(
|
|
"[vector.embed] backstop_interval must not be zero; " +
|
|
"use a negative value to disable or omit for the 24h default")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolvedDBPath returns DBPath if set, else <dataDir>/vectors.db.
|
|
func (c VectorConfig) ResolvedDBPath(dataDir string) string {
|
|
if c.DBPath != "" {
|
|
return c.DBPath
|
|
}
|
|
return filepath.Join(dataDir, "vectors.db")
|
|
}
|
|
|
|
// APIKey reads the API key from the environment variable named by
|
|
// APIKeyEnv. Returns "" when APIKeyEnv is unset.
|
|
func (c VectorEmbeddingsServerConfig) APIKey() string {
|
|
if c.APIKeyEnv == "" {
|
|
return ""
|
|
}
|
|
return os.Getenv(c.APIKeyEnv)
|
|
}
|
|
|
|
// validateServers checks the named-servers section: at least one server, an
|
|
// unambiguous default, and per-server transport settings that parse.
|
|
func (c VectorEmbeddingsConfig) validateServers() error {
|
|
if len(c.Servers) == 0 {
|
|
return fmt.Errorf(
|
|
"[vector.embeddings] at least one server is required when [vector] is enabled; " +
|
|
"define one under [vector.embeddings.servers.<name>]")
|
|
}
|
|
if c.DefaultServer == "" && len(c.Servers) > 1 {
|
|
return fmt.Errorf(
|
|
"[vector.embeddings] default_server is required when more than one server is defined (have: %s)",
|
|
strings.Join(sortedServerNames(c.Servers), ", "))
|
|
}
|
|
if c.DefaultServer != "" {
|
|
if _, ok := c.Servers[c.DefaultServer]; !ok {
|
|
return fmt.Errorf(
|
|
"[vector.embeddings] default_server %q is not a defined server (have: %s)",
|
|
c.DefaultServer, strings.Join(sortedServerNames(c.Servers), ", "))
|
|
}
|
|
}
|
|
for _, name := range sortedServerNames(c.Servers) {
|
|
if err := c.Servers[name].validate(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validate checks one named server's transport settings.
|
|
func (c VectorEmbeddingsServerConfig) validate(name string) error {
|
|
section := fmt.Sprintf("[vector.embeddings.servers.%s]", name)
|
|
if c.Endpoint == "" {
|
|
return fmt.Errorf("%s endpoint is required", section)
|
|
}
|
|
if c.BatchSize <= 0 {
|
|
return fmt.Errorf("%s batch_size must be greater than 0, got %d", section, c.BatchSize)
|
|
}
|
|
if c.Concurrency <= 0 {
|
|
return fmt.Errorf("%s concurrency must be greater than 0, got %d", section, c.Concurrency)
|
|
}
|
|
if c.MaxRetries < 0 {
|
|
return fmt.Errorf("%s max_retries must be >= 0, got %d", section, c.MaxRetries)
|
|
}
|
|
timeout, err := time.ParseDuration(c.Timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("%s invalid timeout %q: %w", section, c.Timeout, err)
|
|
}
|
|
if timeout <= 0 {
|
|
return fmt.Errorf("%s timeout must be greater than 0, got %q", section, c.Timeout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunAfterSyncEnabled reports whether embedding should run after sync,
|
|
// defaulting to true when RunAfterSync is unset.
|
|
func (c VectorEmbedConfig) RunAfterSyncEnabled() bool {
|
|
if c.RunAfterSync == nil {
|
|
return true
|
|
}
|
|
return *c.RunAfterSync
|
|
}
|
|
|
|
// AutomatedConfig holds user-supplied additions to the
|
|
// automated-session classifier. Parse-only; all semantic
|
|
// normalization (trim, dedupe, length cap, built-in overlap
|
|
// drop) happens inside the db setters.
|
|
type AutomatedConfig struct {
|
|
Prefixes []string `toml:"prefixes" json:"prefixes,omitempty"`
|
|
Substrings []string `toml:"substrings" json:"substrings,omitempty"`
|
|
ExactMatches []string `toml:"exact_matches" json:"exact_matches,omitempty"`
|
|
}
|
|
|
|
// AgentConfig holds per-agent runtime overrides.
|
|
type AgentConfig struct {
|
|
Binary string `json:"binary,omitempty" toml:"binary"`
|
|
Sandbox string `json:"sandbox,omitempty" toml:"sandbox"`
|
|
AllowUnsafe bool `json:"allow_unsafe,omitempty" toml:"allow_unsafe"`
|
|
}
|
|
|
|
type CustomModelRate struct {
|
|
Input float64 `json:"input" toml:"input"`
|
|
Output float64 `json:"output" toml:"output"`
|
|
CacheCreation float64 `json:"cache_creation,omitempty" toml:"cache_creation"`
|
|
CacheRead float64 `json:"cache_read,omitempty" toml:"cache_read"`
|
|
}
|
|
|
|
type RemoteTransport string
|
|
|
|
const (
|
|
RemoteTransportSSH RemoteTransport = "ssh"
|
|
RemoteTransportHTTP RemoteTransport = "http"
|
|
)
|
|
|
|
// RemoteHost describes one target for config-driven `agentsview sync`
|
|
// fan-out. Host is required. SSH remotes may set User and Port
|
|
// (Port 0 means the ssh default of 22). HTTP remotes must set URL
|
|
// and Token. A zero/empty Interval disables periodic remote
|
|
// sync for this host.
|
|
type RemoteHost struct {
|
|
Host string `toml:"host" json:"host"`
|
|
Transport RemoteTransport `toml:"transport,omitempty" json:"transport,omitempty"`
|
|
User string `toml:"user,omitempty" json:"user,omitempty"`
|
|
Port int `toml:"port,omitempty" json:"port,omitempty"`
|
|
URL string `toml:"url,omitempty" json:"url,omitempty"`
|
|
Token string `toml:"token,omitempty" json:"-"`
|
|
Interval time.Duration `toml:"interval,omitempty" json:"interval,omitempty"`
|
|
}
|
|
|
|
// Config holds all application configuration.
|
|
type Config struct {
|
|
Host string `json:"host" toml:"host"`
|
|
Port int `json:"port" toml:"port"`
|
|
DataDir string `json:"data_dir" toml:"data_dir"`
|
|
DBPath string `json:"-" toml:"-"`
|
|
PublicURL string `json:"public_url,omitempty" toml:"public_url"`
|
|
PublicOrigins []string `json:"public_origins,omitempty" toml:"public_origins"`
|
|
Proxy ProxyConfig `json:"proxy,omitempty" toml:"proxy"`
|
|
WatchExcludePatterns []string `json:"watch_exclude_patterns,omitempty" toml:"watch_exclude_patterns"`
|
|
CursorSecret string `json:"cursor_secret" toml:"cursor_secret"`
|
|
CursorAdminAPIKey string `json:"cursor_admin_api_key,omitempty" toml:"cursor_admin_api_key"`
|
|
CursorAdminEmail string `json:"cursor_admin_email,omitempty" toml:"cursor_admin_email"`
|
|
CursorAdminUserID string `json:"cursor_admin_user_id,omitempty" toml:"cursor_admin_user_id"`
|
|
GithubToken string `json:"github_token,omitempty" toml:"github_token"`
|
|
Terminal TerminalConfig `json:"terminal,omitempty" toml:"terminal"`
|
|
AuthToken string `json:"auth_token,omitempty" toml:"auth_token"`
|
|
RequireAuth bool `json:"require_auth" toml:"require_auth"`
|
|
NoBrowser bool `json:"no_browser" toml:"no_browser"`
|
|
DisableUpdateCheck bool `json:"disable_update_check" toml:"disable_update_check"`
|
|
NoSync bool `json:"-" toml:"-"`
|
|
SkipInitialSync bool `json:"-" toml:"-"`
|
|
PG PGConfig `json:"pg,omitempty" toml:"pg"`
|
|
DefaultPG string `json:"default_pg,omitempty" toml:"default_pg"`
|
|
PGTargets map[string]PGConfig `json:"-" toml:"-"`
|
|
DuckDB DuckDBConfig `json:"duckdb,omitempty" toml:"duckdb"`
|
|
Vector VectorConfig `json:"vector,omitempty" toml:"vector"`
|
|
Automated AutomatedConfig `json:"automated,omitempty" toml:"automated"`
|
|
Agent map[string]AgentConfig `json:"agent,omitempty" toml:"agent"`
|
|
WriteTimeout time.Duration `json:"-" toml:"-"`
|
|
// LocalMachineName is the operating-system hostname used to identify
|
|
// sessions ingested from this machine. It is runtime-derived rather than
|
|
// persisted configuration so local and remote source labels share the same
|
|
// hostname namespace.
|
|
LocalMachineName string `json:"-" toml:"-"`
|
|
|
|
// AgentDirs maps each AgentType to its configured
|
|
// directories. Single-dir agents store a one-element
|
|
// slice; unconfigured agents use nil.
|
|
AgentDirs map[parser.AgentType][]string `json:"-" toml:"-"`
|
|
|
|
// agentDirSource tracks how each agent's dirs were
|
|
// set so loadFile doesn't override env-set values.
|
|
agentDirSource map[parser.AgentType]dirSource
|
|
|
|
ResultContentBlockedCategories []string `json:"result_content_blocked_categories,omitempty" toml:"result_content_blocked_categories"`
|
|
|
|
// SyncIncludeCwdPrefixes, when non-empty, restricts local session
|
|
// ingestion to sessions whose working directory equals one of the
|
|
// prefixes or lives underneath one. Sessions without a recorded
|
|
// cwd are skipped while the filter is active. Config-file only;
|
|
// remote sync is unaffected because the prefixes describe local
|
|
// paths.
|
|
SyncIncludeCwdPrefixes []string `json:"-" toml:"sync_include_cwd_prefixes"`
|
|
|
|
// EventsCoalesceInterval is the minimum wall-clock time between
|
|
// SSE data_changed broadcasts to connected clients. Emits that
|
|
// arrive within this window after a prior broadcast are coalesced
|
|
// into a single trailing broadcast, bounding dashboard refetch
|
|
// work during bursts of sync activity. Zero disables coalescing.
|
|
EventsCoalesceInterval time.Duration `json:"events_coalesce_interval,omitempty" toml:"events_coalesce_interval"`
|
|
|
|
DaemonIdleTimeout time.Duration `json:"daemon_idle_timeout,omitempty" toml:"daemon_idle_timeout"`
|
|
|
|
CustomModelPricing map[string]CustomModelRate `json:"custom_model_pricing,omitempty" toml:"custom_model_pricing"`
|
|
|
|
// RemoteHosts is the config-file list of remote targets that
|
|
// `agentsview sync` (with no --host) syncs after the local
|
|
// pass. CLI/config-file only; never serialized to the
|
|
// settings API, so there is no web-UI editing of this list.
|
|
RemoteHosts []RemoteHost `json:"-" toml:"-"`
|
|
|
|
// HostExplicit is true when the user passed --host on the CLI.
|
|
// Used to prevent auto-bind to 0.0.0.0 when the user
|
|
// explicitly requested a specific host.
|
|
HostExplicit bool `json:"-" toml:"-"`
|
|
|
|
pgEnvOverrides pgEnvOverrides
|
|
}
|
|
|
|
type dirSource int
|
|
|
|
const (
|
|
dirDefault dirSource = iota
|
|
dirFile
|
|
dirEnv
|
|
)
|
|
|
|
// ResolveDirs returns the effective directories for an agent.
|
|
func (c *Config) ResolveDirs(
|
|
agent parser.AgentType,
|
|
) []string {
|
|
return c.AgentDirs[agent]
|
|
}
|
|
|
|
// IsUserConfigured reports whether the agent's directories
|
|
// were explicitly set by the user (via env var or config file)
|
|
// rather than populated from defaults.
|
|
func (c *Config) IsUserConfigured(
|
|
agent parser.AgentType,
|
|
) bool {
|
|
return c.agentDirSource[agent] != dirDefault
|
|
}
|
|
|
|
// ValidateRemoteHosts checks the configured remote_hosts entries
|
|
// for semantic errors. It checks the trimmed values that loadFile
|
|
// already normalized, so what is validated here is exactly what is
|
|
// passed to remote sync. Returns an aggregated error naming every
|
|
// offending entry, or nil when all entries are valid.
|
|
func (c Config) ValidateRemoteHosts() error {
|
|
var problems []string
|
|
seen := make(map[string]int, len(c.RemoteHosts))
|
|
for i, h := range c.RemoteHosts {
|
|
transport := h.Transport
|
|
if transport == "" {
|
|
transport = RemoteTransportSSH
|
|
}
|
|
if h.Host == "" {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d: host is required", i+1))
|
|
}
|
|
if trimmed := strings.TrimSpace(h.Host); isSSHOptionShaped(h.Host) {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d: host must not begin with '-' (got %q)",
|
|
i+1, trimmed))
|
|
}
|
|
if trimmed := strings.TrimSpace(h.User); isSSHOptionShaped(h.User) {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): user must not begin with '-' (got %q)",
|
|
i+1, h.Host, trimmed))
|
|
}
|
|
if h.Port < 0 || h.Port > 65535 {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): invalid port %d",
|
|
i+1, h.Host, h.Port))
|
|
}
|
|
if h.Interval < 0 {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): invalid interval %s",
|
|
i+1, h.Host, h.Interval))
|
|
}
|
|
switch transport {
|
|
case RemoteTransportSSH:
|
|
if h.URL != "" {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): url is only valid for http",
|
|
i+1, h.Host))
|
|
}
|
|
if h.Token != "" {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): token is only valid for http",
|
|
i+1, h.Host))
|
|
}
|
|
case RemoteTransportHTTP:
|
|
if h.User != "" {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): user is only valid for ssh",
|
|
i+1, h.Host))
|
|
}
|
|
if h.Port != 0 {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): port is only valid for ssh",
|
|
i+1, h.Host))
|
|
}
|
|
if err := validateRemoteHTTPURL(h.URL); err != nil {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): %v",
|
|
i+1, h.Host, err))
|
|
}
|
|
if h.Token == "" {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): token is required for http",
|
|
i+1, h.Host))
|
|
}
|
|
default:
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d (%q): invalid transport %q",
|
|
i+1, h.Host, h.Transport))
|
|
}
|
|
// Remote sync namespaces sessions and the skip cache by
|
|
// host alone (see ssh.RemoteSync), so two entries sharing a
|
|
// host collide regardless of user/port. Reject duplicates
|
|
// rather than silently share or overwrite cached state.
|
|
if h.Host != "" {
|
|
if first, ok := seen[h.Host]; ok {
|
|
problems = append(problems,
|
|
fmt.Sprintf("entry %d: duplicate host %q (already at entry %d)",
|
|
i+1, h.Host, first))
|
|
} else {
|
|
seen[h.Host] = i + 1
|
|
}
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
return fmt.Errorf("remote_hosts: %s",
|
|
strings.Join(problems, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRemoteHTTPURL(raw string) error {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return fmt.Errorf("url is required")
|
|
}
|
|
u, err := url.Parse(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid url: %w", err)
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf("url must use http or https")
|
|
}
|
|
if u.Hostname() == "" {
|
|
return fmt.Errorf("url must include a host")
|
|
}
|
|
if u.User != nil {
|
|
return fmt.Errorf("url must not include userinfo")
|
|
}
|
|
if u.RawQuery != "" || u.ForceQuery {
|
|
return fmt.Errorf("url must not include query")
|
|
}
|
|
if u.Fragment != "" || u.RawFragment != "" || strings.Contains(value, "#") {
|
|
return fmt.Errorf("url must not include fragment")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSSHOptionShaped(value string) bool {
|
|
return strings.HasPrefix(strings.TrimSpace(value), "-")
|
|
}
|
|
|
|
// Default returns a Config with default values.
|
|
func Default() (Config, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf(
|
|
"determining home directory: %w", err,
|
|
)
|
|
}
|
|
dataDir := filepath.Join(home, ".agentsview")
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("identify local sync machine: %w", err)
|
|
}
|
|
if strings.TrimSpace(hostname) == "" {
|
|
return Config{}, fmt.Errorf("identify local sync machine: hostname is empty")
|
|
}
|
|
|
|
agentDirs := make(map[parser.AgentType][]string)
|
|
agentDirSource := make(map[parser.AgentType]dirSource)
|
|
for _, def := range parser.Registry {
|
|
dirs := make([]string, len(def.DefaultDirs))
|
|
root := ""
|
|
if def.DefaultRootEnvVar != "" {
|
|
root = os.Getenv(def.DefaultRootEnvVar)
|
|
}
|
|
for i, rel := range def.DefaultDirs {
|
|
if root != "" {
|
|
dirs[i] = reRootDefaultDir(root, rel)
|
|
continue
|
|
}
|
|
dirs[i] = filepath.Join(home, rel)
|
|
}
|
|
agentDirs[def.Type] = dirs
|
|
agentDirSource[def.Type] = dirDefault
|
|
}
|
|
|
|
return Config{
|
|
Host: "127.0.0.1",
|
|
Port: 8080,
|
|
DataDir: dataDir,
|
|
DBPath: filepath.Join(dataDir, "sessions.db"),
|
|
WriteTimeout: 30 * time.Second,
|
|
LocalMachineName: hostname,
|
|
AgentDirs: agentDirs,
|
|
agentDirSource: agentDirSource,
|
|
WatchExcludePatterns: []string{".git", "node_modules", "__pycache__", ".venv", "venv", "vendor", ".next"},
|
|
ResultContentBlockedCategories: []string{"Read", "Glob"},
|
|
EventsCoalesceInterval: 10 * time.Second,
|
|
DaemonIdleTimeout: 20 * time.Minute,
|
|
Agent: map[string]AgentConfig{},
|
|
Vector: VectorConfig{
|
|
Embeddings: VectorEmbeddingsConfig{
|
|
MaxInputChars: 8192,
|
|
},
|
|
Embed: VectorEmbedConfig{
|
|
BackstopInterval: "24h",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func reRootDefaultDir(root, rel string) string {
|
|
rel = filepath.Clean(rel)
|
|
if _, tail, ok := strings.Cut(rel, string(filepath.Separator)); ok && tail != "" {
|
|
return filepath.Join(root, tail)
|
|
}
|
|
return root
|
|
}
|
|
|
|
// Load builds a Config by layering: defaults < config file < env < flags.
|
|
// The provided FlagSet must already be parsed by the caller.
|
|
// Only flags that were explicitly set override the lower layers.
|
|
func Load(fs *flag.FlagSet) (Config, error) {
|
|
cfg, err := LoadMinimal()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
applyFlags(&cfg, fs)
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadPFlags builds a Config from a parsed Cobra/pflag FlagSet.
|
|
func LoadPFlags(fs *pflag.FlagSet) (Config, error) {
|
|
cfg, err := LoadMinimal()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
applyPFlags(&cfg, fs)
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadPGServe builds a Config for `pg serve` by preserving
|
|
// shared and PG settings from defaults/env/config file while
|
|
// resetting serve-specific network/browser settings to defaults.
|
|
// Only explicitly provided serve flags are applied on top.
|
|
func LoadPGServe(fs *flag.FlagSet) (Config, error) {
|
|
cfg, err := loadPGServeBase()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
applyFlags(&cfg, fs)
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadPGServePFlags builds a PG serve config from a parsed Cobra/pflag FlagSet.
|
|
func LoadPGServePFlags(fs *pflag.FlagSet) (Config, error) {
|
|
cfg, err := loadPGServeBase()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
applyPFlags(&cfg, fs)
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadDuckDBServePFlags builds a DuckDB serve config from a parsed Cobra/pflag
|
|
// FlagSet. It intentionally uses the same isolated serve defaults as pg serve.
|
|
func LoadDuckDBServePFlags(fs *pflag.FlagSet) (Config, error) {
|
|
cfg, err := loadPGServeBase()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
applyPFlags(&cfg, fs)
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func loadPGServeBase() (Config, error) {
|
|
cfg, err := Default()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.loadEnv()
|
|
if err := cfg.loadFile(); err != nil {
|
|
return cfg, fmt.Errorf("loading config file: %w", err)
|
|
}
|
|
if err := cfg.ensureCursorSecret(); err != nil {
|
|
return cfg, fmt.Errorf("ensuring cursor secret: %w", err)
|
|
}
|
|
cfg.DBPath = filepath.Join(cfg.DataDir, "sessions.db")
|
|
|
|
// pg serve intentionally ignores persisted normal serve/public/proxy
|
|
// settings so an existing SQLite-backed serve deployment cannot silently
|
|
// reconfigure the PG-backed server. Until a dedicated pg-serve config
|
|
// namespace exists, only explicit pg-serve flags should shape its
|
|
// network/proxy behavior.
|
|
cfg.Host = "127.0.0.1"
|
|
cfg.Port = 8080
|
|
cfg.PublicURL = ""
|
|
cfg.PublicOrigins = nil
|
|
cfg.Proxy = ProxyConfig{}
|
|
cfg.NoBrowser = false
|
|
cfg.HostExplicit = false
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadMinimal builds a Config from defaults, env, and config file,
|
|
// without parsing CLI flags. Use this for subcommands that manage
|
|
// their own flag sets.
|
|
func LoadMinimal() (Config, error) {
|
|
cfg, err := Default()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.loadEnv()
|
|
|
|
if err := cfg.loadFile(); err != nil {
|
|
return cfg, fmt.Errorf("loading config file: %w", err)
|
|
}
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
if err := cfg.ensureCursorSecret(); err != nil {
|
|
return cfg, fmt.Errorf("ensuring cursor secret: %w", err)
|
|
}
|
|
cfg.DBPath = filepath.Join(cfg.DataDir, "sessions.db")
|
|
return cfg, nil
|
|
}
|
|
|
|
// LoadReadOnly builds a Config from defaults, env, and config.toml without
|
|
// writing config migrations or generated secrets. Use it for diagnostic
|
|
// commands that must not mutate user state.
|
|
func LoadReadOnly() (Config, error) {
|
|
cfg, err := Default()
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.loadEnv()
|
|
|
|
if err := cfg.loadFileReadOnly(); err != nil {
|
|
return cfg, fmt.Errorf("loading config file: %w", err)
|
|
}
|
|
if err := finalize(&cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
cfg.DBPath = filepath.Join(cfg.DataDir, "sessions.db")
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *Config) configPath() string {
|
|
return filepath.Join(c.DataDir, "config.toml")
|
|
}
|
|
|
|
func (c *Config) jsonConfigPath() string {
|
|
return filepath.Join(c.DataDir, "config.json")
|
|
}
|
|
|
|
// migrateJSONToTOML converts config.json to config.toml if
|
|
// config.json exists and config.toml does not. The original
|
|
// JSON file is renamed to config.json.bak.
|
|
func (c *Config) migrateJSONToTOML() error {
|
|
return c.withConfigLock(func() error {
|
|
jsonPath := c.jsonConfigPath()
|
|
tomlPath := c.configPath()
|
|
|
|
if _, err := os.Stat(tomlPath); err == nil {
|
|
return nil // TOML already exists
|
|
}
|
|
data, err := os.ReadFile(jsonPath)
|
|
if os.IsNotExist(err) {
|
|
return nil // no JSON to migrate
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("reading config.json for migration: %w", err)
|
|
}
|
|
|
|
var m map[string]any
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return fmt.Errorf("parsing config.json for migration: %w", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := toml.NewEncoder(&buf).Encode(m); err != nil {
|
|
return fmt.Errorf("encoding config.toml: %w", err)
|
|
}
|
|
if err := os.WriteFile(tomlPath, buf.Bytes(), 0o600); err != nil {
|
|
return fmt.Errorf("writing config.toml: %w", err)
|
|
}
|
|
if err := os.Rename(jsonPath, jsonPath+".bak"); err != nil {
|
|
return fmt.Errorf("renaming config.json to .bak: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (c *Config) loadFile() error {
|
|
return c.loadFileWithMigration(true)
|
|
}
|
|
|
|
func (c *Config) loadFileReadOnly() error {
|
|
return c.loadFileWithMigration(false)
|
|
}
|
|
|
|
func (c *Config) loadFileWithMigration(migrate bool) error {
|
|
if migrate {
|
|
if err := c.migrateJSONToTOML(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
path := c.configPath()
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
if migrate {
|
|
return nil
|
|
}
|
|
return c.loadLegacyJSONReadOnly()
|
|
} else if err != nil {
|
|
return fmt.Errorf("checking config: %w", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("reading config: %w", err)
|
|
}
|
|
return c.applyConfigTOML(string(data))
|
|
}
|
|
|
|
func (c *Config) loadLegacyJSONReadOnly() error {
|
|
data, err := os.ReadFile(c.jsonConfigPath())
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("reading config.json: %w", err)
|
|
}
|
|
|
|
var m map[string]any
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return fmt.Errorf("parsing config.json: %w", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := toml.NewEncoder(&buf).Encode(m); err != nil {
|
|
return fmt.Errorf("encoding config.json: %w", err)
|
|
}
|
|
return c.applyConfigTOML(buf.String())
|
|
}
|
|
|
|
func (c *Config) applyConfigTOML(data string) error {
|
|
var file struct {
|
|
GithubToken string `toml:"github_token"`
|
|
CursorSecret string `toml:"cursor_secret"`
|
|
CursorAdminAPIKey string `toml:"cursor_admin_api_key"`
|
|
CursorAdminEmail string `toml:"cursor_admin_email"`
|
|
CursorAdminUserID string `toml:"cursor_admin_user_id"`
|
|
Host string `toml:"host"`
|
|
Port int `toml:"port"`
|
|
PublicURL string `toml:"public_url"`
|
|
PublicOrigins []string `toml:"public_origins"`
|
|
Proxy ProxyConfig `toml:"proxy"`
|
|
WatchExcludePatterns []string `toml:"watch_exclude_patterns"`
|
|
SyncIncludeCwdPrefixes []string `toml:"sync_include_cwd_prefixes"`
|
|
ResultContentBlockedCategories []string `toml:"result_content_blocked_categories"`
|
|
Terminal TerminalConfig `toml:"terminal"`
|
|
AuthToken string `toml:"auth_token"`
|
|
RequireAuth bool `toml:"require_auth"`
|
|
RemoteAccess bool `toml:"remote_access"`
|
|
DisableUpdateCheck bool `toml:"disable_update_check"`
|
|
DefaultPG string `toml:"default_pg"`
|
|
PG PGConfig `toml:"pg"`
|
|
DuckDB DuckDBConfig `toml:"duckdb"`
|
|
Vector VectorConfig `toml:"vector"`
|
|
Automated AutomatedConfig `toml:"automated"`
|
|
Agent map[string]AgentConfig `toml:"agent"`
|
|
EventsCoalesceInterval time.Duration `toml:"events_coalesce_interval"`
|
|
DaemonIdleTimeout time.Duration `toml:"daemon_idle_timeout"`
|
|
CustomModelPricing map[string]CustomModelRate `toml:"custom_model_pricing"`
|
|
RemoteHosts []RemoteHost `toml:"remote_hosts"`
|
|
}
|
|
meta, err := toml.Decode(data, &file)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing config: %w", err)
|
|
}
|
|
var raw map[string]any
|
|
if _, err := toml.Decode(data, &raw); err != nil {
|
|
return fmt.Errorf("parsing config raw: %w", err)
|
|
}
|
|
if file.GithubToken != "" {
|
|
c.GithubToken = file.GithubToken
|
|
}
|
|
if file.CursorSecret != "" {
|
|
c.CursorSecret = file.CursorSecret
|
|
}
|
|
if file.CursorAdminAPIKey != "" && c.CursorAdminAPIKey == "" {
|
|
c.CursorAdminAPIKey = file.CursorAdminAPIKey
|
|
}
|
|
if file.CursorAdminEmail != "" && c.CursorAdminEmail == "" {
|
|
c.CursorAdminEmail = file.CursorAdminEmail
|
|
}
|
|
if file.CursorAdminUserID != "" && c.CursorAdminUserID == "" {
|
|
c.CursorAdminUserID = file.CursorAdminUserID
|
|
}
|
|
if file.Host != "" {
|
|
c.Host = file.Host
|
|
}
|
|
if file.Port != 0 {
|
|
c.Port = file.Port
|
|
}
|
|
if file.PublicURL != "" {
|
|
c.PublicURL = file.PublicURL
|
|
}
|
|
if file.PublicOrigins != nil {
|
|
c.PublicOrigins = file.PublicOrigins
|
|
}
|
|
if file.Proxy.Mode != "" || file.Proxy.Bin != "" ||
|
|
file.Proxy.BindHost != "" || file.Proxy.PublicPort != 0 ||
|
|
file.Proxy.TLSCert != "" || file.Proxy.TLSKey != "" ||
|
|
file.Proxy.AllowedSubnets != nil {
|
|
c.Proxy = file.Proxy
|
|
}
|
|
if file.WatchExcludePatterns != nil {
|
|
c.WatchExcludePatterns = file.WatchExcludePatterns
|
|
}
|
|
if file.SyncIncludeCwdPrefixes != nil {
|
|
c.SyncIncludeCwdPrefixes = file.SyncIncludeCwdPrefixes
|
|
}
|
|
if file.ResultContentBlockedCategories != nil {
|
|
c.ResultContentBlockedCategories = file.ResultContentBlockedCategories
|
|
}
|
|
if file.Terminal.Mode != "" {
|
|
c.Terminal = file.Terminal
|
|
}
|
|
if file.AuthToken != "" && c.AuthToken == "" {
|
|
c.AuthToken = file.AuthToken
|
|
}
|
|
c.RequireAuth = file.RequireAuth || file.RemoteAccess
|
|
c.DisableUpdateCheck = file.DisableUpdateCheck
|
|
if meta.IsDefined("default_pg") {
|
|
c.DefaultPG = normalizePGTargetName(file.DefaultPG)
|
|
}
|
|
legacyPG, namedPG, err := parsePGConfigSection(raw["pg"])
|
|
if err != nil {
|
|
return fmt.Errorf("pg: %w", err)
|
|
}
|
|
if len(namedPG) > 0 {
|
|
c.PG = PGConfig{}
|
|
c.PGTargets = namedPG
|
|
} else {
|
|
c.PGTargets = nil
|
|
if legacyPG.URL != "" {
|
|
c.PG.URL = legacyPG.URL
|
|
}
|
|
if legacyPG.Schema != "" {
|
|
c.PG.Schema = legacyPG.Schema
|
|
}
|
|
if legacyPG.MachineName != "" {
|
|
c.PG.MachineName = legacyPG.MachineName
|
|
}
|
|
if legacyPG.AllowInsecure {
|
|
c.PG.AllowInsecure = true
|
|
}
|
|
if legacyPG.Projects != nil {
|
|
c.PG.Projects = legacyPG.Projects
|
|
}
|
|
if legacyPG.ExcludeProjects != nil {
|
|
c.PG.ExcludeProjects = legacyPG.ExcludeProjects
|
|
}
|
|
if legacyPG.PushVectors != nil {
|
|
c.PG.PushVectors = legacyPG.PushVectors
|
|
}
|
|
}
|
|
// Merge duckdb field-by-field so env vars override only
|
|
// the fields they set, preserving config-file settings.
|
|
if file.DuckDB.Path != "" && c.DuckDB.Path == "" {
|
|
c.DuckDB.Path = file.DuckDB.Path
|
|
}
|
|
if file.DuckDB.URL != "" && c.DuckDB.URL == "" {
|
|
c.DuckDB.URL = file.DuckDB.URL
|
|
}
|
|
if file.DuckDB.Token != "" && c.DuckDB.Token == "" {
|
|
c.DuckDB.Token = file.DuckDB.Token
|
|
}
|
|
if file.DuckDB.MachineName != "" && c.DuckDB.MachineName == "" {
|
|
c.DuckDB.MachineName = file.DuckDB.MachineName
|
|
}
|
|
if file.DuckDB.AllowInsecure {
|
|
c.DuckDB.AllowInsecure = true
|
|
}
|
|
if file.DuckDB.Projects != nil && c.DuckDB.Projects == nil {
|
|
c.DuckDB.Projects = file.DuckDB.Projects
|
|
}
|
|
if file.DuckDB.ExcludeProjects != nil && c.DuckDB.ExcludeProjects == nil {
|
|
c.DuckDB.ExcludeProjects = file.DuckDB.ExcludeProjects
|
|
}
|
|
if file.Vector.Enabled {
|
|
c.Vector.Enabled = true
|
|
}
|
|
if file.Vector.DBPath != "" {
|
|
c.Vector.DBPath = file.Vector.DBPath
|
|
}
|
|
// IsDefined distinguishes "unset" (keep the default false) from an
|
|
// explicit include_automated = false, matching the other vector
|
|
// section fields' treatment even though both currently agree.
|
|
if meta.IsDefined("vector", "include_automated") {
|
|
c.Vector.IncludeAutomated = file.Vector.IncludeAutomated
|
|
}
|
|
if file.Vector.Embeddings.Model != "" {
|
|
c.Vector.Embeddings.Model = file.Vector.Embeddings.Model
|
|
}
|
|
if file.Vector.Embeddings.Dimension != 0 {
|
|
c.Vector.Embeddings.Dimension = file.Vector.Embeddings.Dimension
|
|
}
|
|
if file.Vector.Embeddings.RequestDimensions {
|
|
c.Vector.Embeddings.RequestDimensions = true
|
|
}
|
|
if meta.IsDefined("vector", "embeddings", "max_input_chars") {
|
|
c.Vector.Embeddings.MaxInputChars = file.Vector.Embeddings.MaxInputChars
|
|
}
|
|
if file.Vector.Embeddings.InputSuffix != "" {
|
|
c.Vector.Embeddings.InputSuffix = file.Vector.Embeddings.InputSuffix
|
|
}
|
|
if file.Vector.Embeddings.DefaultServer != "" {
|
|
c.Vector.Embeddings.DefaultServer = file.Vector.Embeddings.DefaultServer
|
|
}
|
|
if len(file.Vector.Embeddings.Servers) > 0 {
|
|
c.Vector.Embeddings.Servers = normalizedEmbeddingsServers(file.Vector.Embeddings.Servers, meta)
|
|
}
|
|
if file.Vector.Embed.RunAfterSync != nil {
|
|
c.Vector.Embed.RunAfterSync = file.Vector.Embed.RunAfterSync
|
|
}
|
|
if file.Vector.Embed.BackstopInterval != "" {
|
|
c.Vector.Embed.BackstopInterval = file.Vector.Embed.BackstopInterval
|
|
}
|
|
// IsDefined distinguishes "unset" (leave default 10s) from an
|
|
// explicit "0s" (disable coalescing). Checking != 0 would silently
|
|
// ignore the latter.
|
|
if meta.IsDefined("events_coalesce_interval") {
|
|
c.EventsCoalesceInterval = file.EventsCoalesceInterval
|
|
}
|
|
if meta.IsDefined("daemon_idle_timeout") {
|
|
c.DaemonIdleTimeout = file.DaemonIdleTimeout
|
|
}
|
|
if file.Automated.Prefixes != nil {
|
|
c.Automated.Prefixes = file.Automated.Prefixes
|
|
}
|
|
if file.Automated.Substrings != nil {
|
|
c.Automated.Substrings = file.Automated.Substrings
|
|
}
|
|
if file.Automated.ExactMatches != nil {
|
|
c.Automated.ExactMatches = file.Automated.ExactMatches
|
|
}
|
|
if len(file.Agent) > 0 {
|
|
if c.Agent == nil {
|
|
c.Agent = map[string]AgentConfig{}
|
|
}
|
|
for name, cfg := range file.Agent {
|
|
name = strings.TrimSpace(strings.ToLower(name))
|
|
if name == "" {
|
|
continue
|
|
}
|
|
cfg.Binary = strings.TrimSpace(cfg.Binary)
|
|
c.Agent[name] = cfg
|
|
}
|
|
}
|
|
if len(file.CustomModelPricing) > 0 {
|
|
c.CustomModelPricing = file.CustomModelPricing
|
|
}
|
|
if len(file.RemoteHosts) > 0 {
|
|
hosts := make([]RemoteHost, len(file.RemoteHosts))
|
|
for i, h := range file.RemoteHosts {
|
|
hosts[i] = RemoteHost{
|
|
Host: strings.TrimSpace(h.Host),
|
|
Transport: RemoteTransport(strings.TrimSpace(string(h.Transport))),
|
|
User: strings.TrimSpace(h.User),
|
|
Port: h.Port,
|
|
URL: strings.TrimSpace(h.URL),
|
|
Token: strings.TrimSpace(h.Token),
|
|
Interval: h.Interval,
|
|
}
|
|
}
|
|
c.RemoteHosts = hosts
|
|
}
|
|
|
|
// Parse config-file dir arrays for agents that have a
|
|
// ConfigKey. Only apply when not already set by env var.
|
|
for _, def := range parser.Registry {
|
|
if def.ConfigKey == "" {
|
|
continue
|
|
}
|
|
rawVal, exists := raw[def.ConfigKey]
|
|
if !exists {
|
|
continue
|
|
}
|
|
if c.agentDirSource[def.Type] == dirEnv {
|
|
continue
|
|
}
|
|
rawSlice, ok := rawVal.([]any)
|
|
if !ok {
|
|
log.Printf(
|
|
"config: %s: expected string array: got %T",
|
|
def.ConfigKey, rawVal,
|
|
)
|
|
continue
|
|
}
|
|
dirs := make([]string, 0, len(rawSlice))
|
|
for _, v := range rawSlice {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
log.Printf(
|
|
"config: %s: expected string array: element is %T",
|
|
def.ConfigKey, v,
|
|
)
|
|
dirs = nil
|
|
break
|
|
}
|
|
dirs = append(dirs, s)
|
|
}
|
|
if len(dirs) > 0 {
|
|
c.AgentDirs[def.Type] = dirs
|
|
c.agentDirSource[def.Type] = dirFile
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) ensureCursorSecret() error {
|
|
if c.CursorSecret != "" {
|
|
return nil
|
|
}
|
|
|
|
return c.withConfigLock(func() error {
|
|
existing, err := c.readConfigMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if secret, ok := existing["cursor_secret"].(string); ok && secret != "" {
|
|
c.CursorSecret = secret
|
|
return nil
|
|
}
|
|
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return fmt.Errorf("generating secret: %w", err)
|
|
}
|
|
secret := base64.StdEncoding.EncodeToString(b)
|
|
existing["cursor_secret"] = secret
|
|
if err := c.writeConfigMap(existing); err != nil {
|
|
return err
|
|
}
|
|
c.CursorSecret = secret
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// readConfigMap reads the TOML config file into a map. Returns
|
|
// an empty map if the file does not exist.
|
|
func (c *Config) readConfigMap() (map[string]any, error) {
|
|
existing := make(map[string]any)
|
|
data, err := os.ReadFile(c.configPath())
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("reading config: %w", err)
|
|
}
|
|
if err == nil {
|
|
if _, err := toml.Decode(string(data), &existing); err != nil {
|
|
return nil, fmt.Errorf("existing config invalid: %w", err)
|
|
}
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
// writeConfigMap encodes a map as TOML and writes it to the
|
|
// config file.
|
|
func (c *Config) writeConfigMap(m map[string]any) error {
|
|
var buf bytes.Buffer
|
|
if err := toml.NewEncoder(&buf).Encode(m); err != nil {
|
|
return fmt.Errorf("marshaling config: %w", err)
|
|
}
|
|
if err := os.WriteFile(c.configPath(), buf.Bytes(), 0o600); err != nil {
|
|
return fmt.Errorf("writing config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) withConfigLock(fn func() error) error {
|
|
if err := os.MkdirAll(c.DataDir, 0o700); err != nil {
|
|
return fmt.Errorf("creating data dir: %w", err)
|
|
}
|
|
lock := flock.New(c.configPath() + ".lock")
|
|
if err := lock.Lock(); err != nil {
|
|
return fmt.Errorf("locking config: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = lock.Unlock()
|
|
}()
|
|
return fn()
|
|
}
|
|
|
|
// dataDirFromEnv returns the data directory from the environment, preferring
|
|
// AGENTSVIEW_DATA_DIR and falling back to the legacy AGENT_VIEWER_DATA_DIR.
|
|
// Returns "" when neither is set.
|
|
func dataDirFromEnv() string {
|
|
if v := os.Getenv("AGENTSVIEW_DATA_DIR"); v != "" {
|
|
return v
|
|
}
|
|
return os.Getenv("AGENT_VIEWER_DATA_DIR")
|
|
}
|
|
|
|
func (c *Config) loadEnv() {
|
|
for _, def := range parser.Registry {
|
|
if v := os.Getenv(def.EnvVar); v != "" {
|
|
c.AgentDirs[def.Type] = []string{v}
|
|
c.agentDirSource[def.Type] = dirEnv
|
|
}
|
|
}
|
|
if v := dataDirFromEnv(); v != "" {
|
|
c.DataDir = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_AUTH_TOKEN"); v != "" {
|
|
c.AuthToken = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_PG_URL"); v != "" {
|
|
c.pgEnvOverrides.URL = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_PG_SCHEMA"); v != "" {
|
|
c.pgEnvOverrides.Schema = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_PG_MACHINE"); v != "" {
|
|
c.pgEnvOverrides.MachineName = v
|
|
}
|
|
if v := firstEnv(
|
|
"AGENTSVIEW_CURSOR_ADMIN_API_KEY",
|
|
"CURSOR_ADMIN_API_KEY",
|
|
); v != "" {
|
|
c.CursorAdminAPIKey = v
|
|
}
|
|
if v := firstEnv(
|
|
"AGENTSVIEW_CURSOR_ADMIN_EMAIL",
|
|
"CURSOR_ADMIN_EMAIL",
|
|
); v != "" {
|
|
c.CursorAdminEmail = v
|
|
}
|
|
if v := firstEnv(
|
|
"AGENTSVIEW_CURSOR_ADMIN_USER_ID",
|
|
"CURSOR_ADMIN_USER_ID",
|
|
); v != "" {
|
|
c.CursorAdminUserID = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_DUCKDB_PATH"); v != "" {
|
|
c.DuckDB.Path = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_DUCKDB_URL"); v != "" {
|
|
c.DuckDB.URL = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_DUCKDB_TOKEN"); v != "" {
|
|
c.DuckDB.Token = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_DUCKDB_MACHINE"); v != "" {
|
|
c.DuckDB.MachineName = v
|
|
}
|
|
if v := os.Getenv("AGENTSVIEW_DISABLE_UPDATE_CHECK"); v != "" {
|
|
c.DisableUpdateCheck = v == "1" || v == "true"
|
|
}
|
|
}
|
|
|
|
func firstEnv(names ...string) string {
|
|
for _, name := range names {
|
|
if v := os.Getenv(name); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type stringListFlag []string
|
|
|
|
func (f *stringListFlag) String() string {
|
|
return strings.Join(*f, ",")
|
|
}
|
|
|
|
func (f *stringListFlag) Set(value string) error {
|
|
for part := range strings.SplitSeq(value, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
*f = append(*f, part)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *stringListFlag) Type() string {
|
|
return "stringList"
|
|
}
|
|
|
|
// RegisterServeFlags registers serve-command flags on fs.
|
|
// The caller must call fs.Parse before passing fs to Load.
|
|
func RegisterServeFlags(fs *flag.FlagSet) {
|
|
fs.String("host", "127.0.0.1", "Host to bind to")
|
|
fs.Int("port", 8080, "Port to listen on")
|
|
fs.String(
|
|
"public-url", "",
|
|
"Public URL to trust and open for hostname or proxy access",
|
|
)
|
|
fs.Var(
|
|
&stringListFlag{},
|
|
"public-origin",
|
|
"Trusted browser origin to allow for remote or proxied access (repeatable or comma-separated)",
|
|
)
|
|
fs.String(
|
|
"proxy", "",
|
|
"Managed reverse proxy mode (currently: caddy)",
|
|
)
|
|
fs.String(
|
|
"caddy-bin", "",
|
|
"Caddy binary to use when -proxy=caddy (default: caddy)",
|
|
)
|
|
fs.String(
|
|
"proxy-bind-host", "",
|
|
"Local interface/IP for managed Caddy to bind (default: 0.0.0.0)",
|
|
)
|
|
fs.Int(
|
|
"public-port", 0,
|
|
"External port for the public URL in managed Caddy mode (default: 8443)",
|
|
)
|
|
fs.String(
|
|
"tls-cert", "",
|
|
"TLS certificate path for managed Caddy HTTPS mode",
|
|
)
|
|
fs.String(
|
|
"tls-key", "",
|
|
"TLS key path for managed Caddy HTTPS mode",
|
|
)
|
|
fs.Var(
|
|
&stringListFlag{},
|
|
"allowed-subnet",
|
|
"Client CIDR allowed to connect to the managed proxy (repeatable or comma-separated)",
|
|
)
|
|
fs.Bool(
|
|
"no-browser", false,
|
|
"Don't open browser on startup",
|
|
)
|
|
fs.Bool(
|
|
"no-sync", false,
|
|
"Skip initial sync and disable background sync/file watching",
|
|
)
|
|
fs.Bool(
|
|
"no-update-check", false,
|
|
"Disable the update check API endpoint",
|
|
)
|
|
fs.Bool(
|
|
"require-auth", false,
|
|
"Require a bearer token for all API requests",
|
|
)
|
|
fs.Duration(
|
|
"events-coalesce-interval", 10*time.Second,
|
|
"Minimum interval between SSE data_changed broadcasts (0 disables coalescing)",
|
|
)
|
|
}
|
|
|
|
// RegisterServePFlags registers serve-command flags on fs.
|
|
func RegisterServePFlags(fs *pflag.FlagSet) {
|
|
fs.String("host", "127.0.0.1", "Host to bind to")
|
|
fs.Int("port", 8080, "Port to listen on")
|
|
fs.String(
|
|
"public-url", "",
|
|
"Public URL to trust and open for hostname or proxy access",
|
|
)
|
|
fs.Var(
|
|
&stringListFlag{},
|
|
"public-origin",
|
|
"Trusted browser origin to allow for remote or proxied access (repeatable or comma-separated)",
|
|
)
|
|
fs.String(
|
|
"proxy", "",
|
|
"Managed reverse proxy mode (currently: caddy)",
|
|
)
|
|
fs.String(
|
|
"caddy-bin", "",
|
|
"Caddy binary to use when -proxy=caddy (default: caddy)",
|
|
)
|
|
fs.String(
|
|
"proxy-bind-host", "",
|
|
"Local interface/IP for managed Caddy to bind (default: 0.0.0.0)",
|
|
)
|
|
fs.Int(
|
|
"public-port", 0,
|
|
"External port for the public URL in managed Caddy mode (default: 8443)",
|
|
)
|
|
fs.String(
|
|
"tls-cert", "",
|
|
"TLS certificate path for managed Caddy HTTPS mode",
|
|
)
|
|
fs.String(
|
|
"tls-key", "",
|
|
"TLS key path for managed Caddy HTTPS mode",
|
|
)
|
|
fs.Var(
|
|
&stringListFlag{},
|
|
"allowed-subnet",
|
|
"Client CIDR allowed to connect to the managed proxy (repeatable or comma-separated)",
|
|
)
|
|
fs.Bool(
|
|
"no-browser", false,
|
|
"Don't open browser on startup",
|
|
)
|
|
fs.Bool(
|
|
"no-sync", false,
|
|
"Skip initial sync and disable background sync/file watching",
|
|
)
|
|
fs.Bool(
|
|
"no-update-check", false,
|
|
"Disable the update check API endpoint",
|
|
)
|
|
fs.Bool(
|
|
"require-auth", false,
|
|
"Require a bearer token for all API requests",
|
|
)
|
|
fs.Duration(
|
|
"events-coalesce-interval", 10*time.Second,
|
|
"Minimum interval between SSE data_changed broadcasts (0 disables coalescing)",
|
|
)
|
|
}
|
|
|
|
// applyFlags copies explicitly-set flags from fs into cfg.
|
|
func applyFlags(cfg *Config, fs *flag.FlagSet) {
|
|
if fs == nil {
|
|
return
|
|
}
|
|
fs.Visit(func(f *flag.Flag) {
|
|
applyFlagValue(cfg, f.Name, f.Value.String())
|
|
})
|
|
}
|
|
|
|
// applyPFlags copies explicitly-set pflags from fs into cfg.
|
|
func applyPFlags(cfg *Config, fs *pflag.FlagSet) {
|
|
if fs == nil {
|
|
return
|
|
}
|
|
fs.Visit(func(f *pflag.Flag) {
|
|
applyFlagValue(cfg, f.Name, f.Value.String())
|
|
})
|
|
}
|
|
|
|
func applyFlagValue(cfg *Config, name, value string) {
|
|
switch name {
|
|
case "host":
|
|
cfg.Host = value
|
|
cfg.HostExplicit = true
|
|
case "port":
|
|
cfg.Port, _ = strconv.Atoi(value)
|
|
case "public-url":
|
|
cfg.PublicURL = value
|
|
case "public-origin":
|
|
cfg.PublicOrigins = splitFlagList(value)
|
|
case "proxy":
|
|
cfg.Proxy.Mode = value
|
|
case "caddy-bin":
|
|
cfg.Proxy.Bin = value
|
|
case "proxy-bind-host":
|
|
cfg.Proxy.BindHost = value
|
|
case "public-port":
|
|
cfg.Proxy.PublicPort, _ = strconv.Atoi(value)
|
|
case "tls-cert":
|
|
cfg.Proxy.TLSCert = value
|
|
case "tls-key":
|
|
cfg.Proxy.TLSKey = value
|
|
case "allowed-subnet":
|
|
cfg.Proxy.AllowedSubnets = splitFlagList(value)
|
|
case "no-browser":
|
|
cfg.NoBrowser = value == "true"
|
|
case "no-sync":
|
|
cfg.NoSync = value == "true"
|
|
case "no-update-check":
|
|
cfg.DisableUpdateCheck = value == "true"
|
|
case "require-auth":
|
|
cfg.RequireAuth = value == "true"
|
|
case "events-coalesce-interval":
|
|
if d, err := time.ParseDuration(value); err == nil {
|
|
cfg.EventsCoalesceInterval = d
|
|
}
|
|
case "pg":
|
|
// Read-routing only. The CLI resolver combines this flag
|
|
// with cfg.PG from env/config and does not persist a new
|
|
// config field for it.
|
|
}
|
|
}
|
|
|
|
func splitFlagList(value string) []string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for part := range strings.SplitSeq(value, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
out = append(out, part)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func finalize(cfg *Config) error {
|
|
var err error
|
|
if strings.TrimSpace(cfg.LocalMachineName) == "" {
|
|
return fmt.Errorf("identify local sync machine: hostname is empty")
|
|
}
|
|
if err := normalizeProxyConfig(&cfg.Proxy); err != nil {
|
|
return err
|
|
}
|
|
cfg.PublicURL, err = resolvePublicURL(cfg.PublicURL, cfg.Proxy)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid public url: %w", err)
|
|
}
|
|
cfg.PublicOrigins, err = normalizePublicOrigins(cfg.PublicOrigins)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid public origins: %w", err)
|
|
}
|
|
if cfg.PublicURL != "" {
|
|
cfg.PublicOrigins, err = normalizePublicOrigins(
|
|
append(cfg.PublicOrigins, cfg.PublicURL),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid public url: %w", err)
|
|
}
|
|
}
|
|
if cfg.DaemonIdleTimeout < 0 {
|
|
return fmt.Errorf("invalid daemon_idle_timeout: %s", cfg.DaemonIdleTimeout)
|
|
}
|
|
if err := cfg.Vector.Validate(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolvePublicURL(value string, proxyCfg ProxyConfig) (string, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "", nil
|
|
}
|
|
u, err := url.Parse(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if u == nil || u.Host == "" {
|
|
return "", fmt.Errorf("%q must include a host", value)
|
|
}
|
|
if u.User != nil {
|
|
return "", fmt.Errorf("%q must not include user info", value)
|
|
}
|
|
if u.RawQuery != "" || u.Fragment != "" {
|
|
return "", fmt.Errorf("%q must not include query or fragment", value)
|
|
}
|
|
if u.Path != "" && u.Path != "/" {
|
|
return "", fmt.Errorf("%q must not include a path", value)
|
|
}
|
|
if proxyCfg.Mode != "caddy" {
|
|
return normalizePublicOrigin(value)
|
|
}
|
|
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return "", fmt.Errorf("%q must use http or https", value)
|
|
}
|
|
resolvedPort := proxyCfg.PublicPort
|
|
if resolvedPort == 0 {
|
|
resolvedPort = 8443
|
|
}
|
|
if rawPort := u.Port(); rawPort != "" {
|
|
explicitPort, err := strconv.Atoi(rawPort)
|
|
if err != nil || explicitPort < 1 || explicitPort > 65535 {
|
|
return "", fmt.Errorf("%q has an invalid port", value)
|
|
}
|
|
if proxyCfg.PublicPort != 0 && explicitPort != proxyCfg.PublicPort {
|
|
return "", fmt.Errorf(
|
|
"%q conflicts with configured public port %d",
|
|
value, proxyCfg.PublicPort,
|
|
)
|
|
}
|
|
resolvedPort = explicitPort
|
|
}
|
|
|
|
host := strings.ToLower(u.Hostname())
|
|
if host == "" {
|
|
return "", fmt.Errorf("%q must include a host", value)
|
|
}
|
|
if resolvedPort == defaultPortForScheme(scheme) {
|
|
return scheme + "://" + hostLiteral(host), nil
|
|
}
|
|
return scheme + "://" + net.JoinHostPort(host, strconv.Itoa(resolvedPort)), nil
|
|
}
|
|
|
|
func normalizePublicOrigins(origins []string) ([]string, error) {
|
|
if len(origins) == 0 {
|
|
return nil, nil
|
|
}
|
|
normalized := make([]string, 0, len(origins))
|
|
seen := make(map[string]bool, len(origins))
|
|
for _, origin := range origins {
|
|
if strings.TrimSpace(origin) == "" {
|
|
continue
|
|
}
|
|
norm, err := normalizePublicOrigin(origin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if seen[norm] {
|
|
continue
|
|
}
|
|
seen[norm] = true
|
|
normalized = append(normalized, norm)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, nil
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func normalizePublicOrigin(origin string) (string, error) {
|
|
origin = strings.TrimSpace(origin)
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing %q: %w", origin, err)
|
|
}
|
|
if u == nil || u.Host == "" {
|
|
return "", fmt.Errorf("%q must include a host", origin)
|
|
}
|
|
if u.User != nil {
|
|
return "", fmt.Errorf("%q must not include user info", origin)
|
|
}
|
|
if u.RawQuery != "" || u.Fragment != "" {
|
|
return "", fmt.Errorf("%q must not include query or fragment", origin)
|
|
}
|
|
if u.Path != "" && u.Path != "/" {
|
|
return "", fmt.Errorf("%q must not include a path", origin)
|
|
}
|
|
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return "", fmt.Errorf("%q must use http or https", origin)
|
|
}
|
|
|
|
host := strings.ToLower(u.Hostname())
|
|
if host == "" {
|
|
return "", fmt.Errorf("%q must include a host", origin)
|
|
}
|
|
port := u.Port()
|
|
if port != "" {
|
|
n, err := strconv.Atoi(port)
|
|
if err != nil || n < 1 || n > 65535 {
|
|
return "", fmt.Errorf("%q has an invalid port", origin)
|
|
}
|
|
if n == defaultPortForScheme(scheme) {
|
|
port = ""
|
|
}
|
|
}
|
|
|
|
if port == "" {
|
|
return scheme + "://" + hostLiteral(host), nil
|
|
}
|
|
return scheme + "://" + net.JoinHostPort(host, port), nil
|
|
}
|
|
|
|
func normalizeProxyConfig(cfg *ProxyConfig) error {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
cfg.Mode = strings.ToLower(strings.TrimSpace(cfg.Mode))
|
|
switch cfg.Mode {
|
|
case "", "caddy":
|
|
default:
|
|
return fmt.Errorf("invalid proxy mode %q", cfg.Mode)
|
|
}
|
|
if cfg.Mode == "caddy" && strings.TrimSpace(cfg.Bin) == "" {
|
|
cfg.Bin = "caddy"
|
|
}
|
|
if cfg.Mode == "caddy" {
|
|
cfg.BindHost = strings.TrimSpace(cfg.BindHost)
|
|
if cfg.BindHost == "" {
|
|
cfg.BindHost = "127.0.0.1"
|
|
}
|
|
if cfg.PublicPort < 0 || cfg.PublicPort > 65535 {
|
|
return fmt.Errorf("invalid public port %d", cfg.PublicPort)
|
|
}
|
|
}
|
|
var err error
|
|
cfg.AllowedSubnets, err = normalizeAllowedSubnets(cfg.AllowedSubnets)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid allowed subnets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeAllowedSubnets(subnets []string) ([]string, error) {
|
|
if len(subnets) == 0 {
|
|
return nil, nil
|
|
}
|
|
normalized := make([]string, 0, len(subnets))
|
|
seen := make(map[string]bool, len(subnets))
|
|
for _, subnet := range subnets {
|
|
subnet = strings.TrimSpace(subnet)
|
|
if subnet == "" {
|
|
continue
|
|
}
|
|
network, err := parseAllowedSubnet(subnet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing %q: %w", subnet, err)
|
|
}
|
|
value := network.String()
|
|
if seen[value] {
|
|
continue
|
|
}
|
|
seen[value] = true
|
|
normalized = append(normalized, value)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return nil, nil
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func parseAllowedSubnet(value string) (*net.IPNet, error) {
|
|
_, network, err := net.ParseCIDR(value)
|
|
if err == nil {
|
|
return network, nil
|
|
}
|
|
expanded, ok := expandIPv4CIDRShorthand(value)
|
|
if !ok {
|
|
return nil, err
|
|
}
|
|
_, network, err = net.ParseCIDR(expanded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return network, nil
|
|
}
|
|
|
|
func expandIPv4CIDRShorthand(value string) (string, bool) {
|
|
addr, mask, ok := strings.Cut(value, "/")
|
|
if !ok || strings.Contains(addr, ":") {
|
|
return "", false
|
|
}
|
|
parts := strings.Split(addr, ".")
|
|
if len(parts) == 0 || len(parts) > 4 {
|
|
return "", false
|
|
}
|
|
if slices.Contains(parts, "") {
|
|
return "", false
|
|
}
|
|
for len(parts) < 4 {
|
|
parts = append(parts, "0")
|
|
}
|
|
return strings.Join(parts, ".") + "/" + mask, true
|
|
}
|
|
|
|
func defaultPortForScheme(scheme string) int {
|
|
if scheme == "https" {
|
|
return 443
|
|
}
|
|
return 80
|
|
}
|
|
|
|
func hostLiteral(host string) string {
|
|
if strings.Contains(host, ":") {
|
|
return "[" + host + "]"
|
|
}
|
|
return host
|
|
}
|
|
|
|
func normalizePGTargetName(name string) string {
|
|
return strings.TrimSpace(strings.ToLower(name))
|
|
}
|
|
|
|
func isReservedPGTargetName(name string) bool {
|
|
switch normalizePGTargetName(name) {
|
|
case "all", "local":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func decodePGConfigMap(raw map[string]any) (PGConfig, error) {
|
|
var buf bytes.Buffer
|
|
if err := toml.NewEncoder(&buf).Encode(raw); err != nil {
|
|
return PGConfig{}, fmt.Errorf("encoding pg config: %w", err)
|
|
}
|
|
var cfg PGConfig
|
|
if _, err := toml.Decode(buf.String(), &cfg); err != nil {
|
|
return PGConfig{}, fmt.Errorf("decoding pg config: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func parsePGConfigSection(value any) (PGConfig, map[string]PGConfig, error) {
|
|
if value == nil {
|
|
return PGConfig{}, nil, nil
|
|
}
|
|
section, ok := value.(map[string]any)
|
|
if !ok {
|
|
return PGConfig{}, nil, fmt.Errorf("expected [pg] to be a table")
|
|
}
|
|
hasLegacyFields := false
|
|
hasNamedTargets := false
|
|
legacyRaw := make(map[string]any)
|
|
namedTargets := make(map[string]PGConfig)
|
|
seenNames := make(map[string]string)
|
|
for rawName, rawValue := range section {
|
|
name := normalizePGTargetName(rawName)
|
|
if _, ok := pgConfigKeys[name]; ok {
|
|
if _, nested := rawValue.(map[string]any); nested {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"[pg].%s must be a scalar or array field, not a nested table",
|
|
rawName,
|
|
)
|
|
}
|
|
hasLegacyFields = true
|
|
legacyRaw[rawName] = rawValue
|
|
continue
|
|
}
|
|
targetRaw, ok := rawValue.(map[string]any)
|
|
if !ok {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"[pg].%s must be a named target table",
|
|
rawName,
|
|
)
|
|
}
|
|
hasNamedTargets = true
|
|
if name == "" {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"named PG targets must not be blank",
|
|
)
|
|
}
|
|
if isReservedPGTargetName(name) {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"named PG target %q is reserved",
|
|
name,
|
|
)
|
|
}
|
|
if prev, exists := seenNames[name]; exists {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"named PG targets %q and %q normalize to the same name %q",
|
|
prev, rawName, name,
|
|
)
|
|
}
|
|
seenNames[name] = rawName
|
|
targetCfg, err := decodePGConfigMap(targetRaw)
|
|
if err != nil {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"[pg].%s: %w", rawName, err,
|
|
)
|
|
}
|
|
namedTargets[name] = targetCfg
|
|
}
|
|
if hasLegacyFields && hasNamedTargets {
|
|
return PGConfig{}, nil, fmt.Errorf(
|
|
"cannot mix legacy [pg] fields with named [pg.NAME] targets",
|
|
)
|
|
}
|
|
if hasLegacyFields {
|
|
legacyCfg, err := decodePGConfigMap(legacyRaw)
|
|
if err != nil {
|
|
return PGConfig{}, nil, err
|
|
}
|
|
return legacyCfg, nil, nil
|
|
}
|
|
if hasNamedTargets {
|
|
return PGConfig{}, namedTargets, nil
|
|
}
|
|
return PGConfig{}, nil, nil
|
|
}
|
|
|
|
// ResolveDataDir returns the effective data directory by applying
|
|
// defaults and environment overrides, without reading any files.
|
|
// Use this to determine where migration should target before
|
|
// calling Load or LoadMinimal.
|
|
func ResolveDataDir() (string, error) {
|
|
cfg, err := Default()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if v := dataDirFromEnv(); v != "" {
|
|
cfg.DataDir = v
|
|
}
|
|
return cfg.DataDir, nil
|
|
}
|
|
|
|
// IsDefaultAgentsviewDataDir reports whether path is (or symlink-resolves to) a
|
|
// default ~/.agentsview data directory. It is the single guard shared by the
|
|
// CLI and HTTP recall-import paths.
|
|
func IsDefaultAgentsviewDataDir(path string) bool {
|
|
clean := filepath.Clean(strings.TrimSpace(path))
|
|
if clean == "" || clean == "." {
|
|
return false
|
|
}
|
|
if filepath.Base(clean) == ".agentsview" {
|
|
return true
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(clean)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return filepath.Base(filepath.Clean(resolved)) == ".agentsview"
|
|
}
|
|
|
|
// IsDefaultAgentsviewDBPath reports whether dbPath lives inside a default
|
|
// ~/.agentsview directory after resolving symlinks. It also resolves a direct
|
|
// symlink whose target does not exist yet, so a lab sessions.db pointing at a
|
|
// not-yet-created ~/.agentsview/sessions.db is still guarded: opening SQLite
|
|
// through that dangling link would otherwise create the production archive.
|
|
func IsDefaultAgentsviewDBPath(dbPath string) bool {
|
|
dbPath = strings.TrimSpace(dbPath)
|
|
if dbPath == "" {
|
|
return false
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(dbPath); err == nil {
|
|
if defaultAgentsviewDBDir(resolved) {
|
|
return true
|
|
}
|
|
}
|
|
if target, err := os.Readlink(dbPath); err == nil {
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(filepath.Dir(dbPath), target)
|
|
}
|
|
if defaultAgentsviewDBDir(target) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func defaultAgentsviewDBDir(dbPath string) bool {
|
|
return filepath.Base(filepath.Dir(filepath.Clean(dbPath))) == ".agentsview"
|
|
}
|
|
|
|
// DefaultPGTargetName returns the effective named PG target for this config.
|
|
func (c *Config) DefaultPGTargetName() (string, error) {
|
|
if len(c.PGTargets) == 0 {
|
|
if c.DefaultPG != "" {
|
|
return "", fmt.Errorf(
|
|
"default_pg requires named [pg.NAME] targets",
|
|
)
|
|
}
|
|
return "", nil
|
|
}
|
|
if c.DefaultPG != "" {
|
|
if _, ok := c.PGTargets[c.DefaultPG]; !ok {
|
|
return "", fmt.Errorf(
|
|
"default_pg %q does not match any named [pg.NAME] target",
|
|
c.DefaultPG,
|
|
)
|
|
}
|
|
return c.DefaultPG, nil
|
|
}
|
|
if len(c.PGTargets) == 1 {
|
|
for name := range c.PGTargets {
|
|
return name, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf(
|
|
"default_pg is required when more than one [pg.NAME] target is defined",
|
|
)
|
|
}
|
|
|
|
func (c *Config) validatePGTargets() error {
|
|
_, err := c.DefaultPGTargetName()
|
|
return err
|
|
}
|
|
|
|
func (c *Config) PGTargetNames() ([]string, string, error) {
|
|
if err := c.validatePGTargets(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(c.PGTargets) == 0 {
|
|
return nil, "", nil
|
|
}
|
|
defaultName, err := c.DefaultPGTargetName()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
names := make([]string, 0, len(c.PGTargets))
|
|
for name := range c.PGTargets {
|
|
names = append(names, name)
|
|
}
|
|
sort.Slice(names, func(i, j int) bool {
|
|
if names[i] == defaultName {
|
|
return true
|
|
}
|
|
if names[j] == defaultName {
|
|
return false
|
|
}
|
|
return names[i] < names[j]
|
|
})
|
|
return names, defaultName, nil
|
|
}
|
|
|
|
// RawPGTarget returns the configured PG target before env expansion and
|
|
// default-field synthesis. An empty name selects the effective default target.
|
|
func (c *Config) RawPGTarget(name string) (PGConfig, error) {
|
|
if err := c.validatePGTargets(); err != nil {
|
|
return PGConfig{}, err
|
|
}
|
|
targetName := normalizePGTargetName(name)
|
|
if len(c.PGTargets) == 0 {
|
|
if targetName != "" {
|
|
return PGConfig{}, fmt.Errorf(
|
|
"pg target %q is not configured; config uses a single legacy [pg] block",
|
|
name,
|
|
)
|
|
}
|
|
return c.PG, nil
|
|
}
|
|
if targetName == "" {
|
|
var err error
|
|
targetName, err = c.DefaultPGTargetName()
|
|
if err != nil {
|
|
return PGConfig{}, err
|
|
}
|
|
}
|
|
targetCfg, ok := c.PGTargets[targetName]
|
|
if !ok {
|
|
return PGConfig{}, fmt.Errorf(
|
|
"pg target %q is not configured",
|
|
targetName,
|
|
)
|
|
}
|
|
return targetCfg, nil
|
|
}
|
|
|
|
func (c *Config) resolvePGConfig(
|
|
pg PGConfig, applyDefaultEnv bool,
|
|
) (PGConfig, error) {
|
|
if applyDefaultEnv {
|
|
if c.pgEnvOverrides.URL != "" {
|
|
pg.URL = c.pgEnvOverrides.URL
|
|
}
|
|
if c.pgEnvOverrides.Schema != "" {
|
|
pg.Schema = c.pgEnvOverrides.Schema
|
|
}
|
|
if c.pgEnvOverrides.MachineName != "" {
|
|
pg.MachineName = c.pgEnvOverrides.MachineName
|
|
}
|
|
}
|
|
if pg.URL != "" {
|
|
expanded, err := expandBracedEnv(pg.URL)
|
|
if err != nil {
|
|
return pg, fmt.Errorf("expanding url: %w", err)
|
|
}
|
|
pg.URL = expanded
|
|
}
|
|
if pg.Schema == "" {
|
|
pg.Schema = "agentsview"
|
|
}
|
|
if pg.MachineName == "" {
|
|
h, err := os.Hostname()
|
|
if err != nil {
|
|
return pg, fmt.Errorf("os.Hostname failed (%w); set machine_name explicitly in config", err)
|
|
}
|
|
pg.MachineName = h
|
|
}
|
|
return pg, nil
|
|
}
|
|
|
|
// ResolvePG returns the effective default PG target with defaults applied
|
|
// and environment variables expanded in URL.
|
|
func (c *Config) ResolvePG() (PGConfig, error) {
|
|
return c.ResolvePGTarget("")
|
|
}
|
|
|
|
// ResolvePGTarget resolves one named PG target, or the effective default
|
|
// target when name is empty. In legacy single-target mode, only the empty
|
|
// name is valid.
|
|
func (c *Config) ResolvePGTarget(name string) (PGConfig, error) {
|
|
if err := c.validatePGTargets(); err != nil {
|
|
return PGConfig{}, err
|
|
}
|
|
targetName := normalizePGTargetName(name)
|
|
if len(c.PGTargets) == 0 {
|
|
if targetName != "" {
|
|
return PGConfig{}, fmt.Errorf(
|
|
"pg target %q is not configured; config uses a single legacy [pg] block",
|
|
name,
|
|
)
|
|
}
|
|
return c.resolvePGConfig(c.PG, true)
|
|
}
|
|
defaultName, err := c.DefaultPGTargetName()
|
|
if err != nil {
|
|
return PGConfig{}, err
|
|
}
|
|
if targetName == "" {
|
|
targetName = defaultName
|
|
}
|
|
targetCfg, ok := c.PGTargets[targetName]
|
|
if !ok {
|
|
return PGConfig{}, fmt.Errorf(
|
|
"pg target %q is not configured",
|
|
targetName,
|
|
)
|
|
}
|
|
return c.resolvePGConfig(targetCfg, targetName == defaultName)
|
|
}
|
|
|
|
// ResolvePGTargets resolves every configured PG target. Legacy single-target
|
|
// mode returns one unnamed default target.
|
|
func (c *Config) ResolvePGTargets() ([]ResolvedPGTarget, error) {
|
|
if err := c.validatePGTargets(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(c.PGTargets) == 0 {
|
|
pg, err := c.resolvePGConfig(c.PG, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []ResolvedPGTarget{{
|
|
Config: pg,
|
|
IsDefault: true,
|
|
}}, nil
|
|
}
|
|
names, defaultName, err := c.PGTargetNames()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets := make([]ResolvedPGTarget, 0, len(names))
|
|
for _, name := range names {
|
|
targetCfg, err := c.resolvePGConfig(
|
|
c.PGTargets[name], name == defaultName,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets = append(targets, ResolvedPGTarget{
|
|
Name: name,
|
|
Config: targetCfg,
|
|
IsDefault: name == defaultName,
|
|
})
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
// ResolveDuckDB returns a copy of DuckDB config with defaults applied
|
|
// and environment variables expanded in path, URL, and token.
|
|
func (c *Config) ResolveDuckDB() (DuckDBConfig, error) {
|
|
duck := c.DuckDB
|
|
if duck.Path != "" {
|
|
expanded, err := expandBracedEnv(duck.Path)
|
|
if err != nil {
|
|
return duck, fmt.Errorf("expanding path: %w", err)
|
|
}
|
|
duck.Path = expanded
|
|
}
|
|
if duck.URL != "" {
|
|
expanded, err := expandBracedEnv(duck.URL)
|
|
if err != nil {
|
|
return duck, fmt.Errorf("expanding url: %w", err)
|
|
}
|
|
duck.URL = expanded
|
|
}
|
|
if duck.Token != "" {
|
|
expanded, err := expandBracedEnv(duck.Token)
|
|
if err != nil {
|
|
return duck, fmt.Errorf("expanding token: %w", err)
|
|
}
|
|
duck.Token = expanded
|
|
}
|
|
if duck.Path == "" {
|
|
duck.Path = filepath.Join(c.DataDir, "sessions.duckdb")
|
|
}
|
|
if duck.MachineName == "" {
|
|
h, err := os.Hostname()
|
|
if err != nil {
|
|
return duck, fmt.Errorf("os.Hostname failed (%w); set machine_name explicitly in config", err)
|
|
}
|
|
duck.MachineName = h
|
|
}
|
|
return duck, nil
|
|
}
|
|
|
|
var (
|
|
bracedEnvPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
|
|
bareEnvPattern = regexp.MustCompile(`^\$([A-Za-z_][A-Za-z0-9_]*)$`)
|
|
partialBareEnvPattern = regexp.MustCompile(`\$([A-Za-z_][A-Za-z0-9_]*)`)
|
|
)
|
|
|
|
// IsEnvDependentURL reports whether s would have environment variables
|
|
// expanded by expandBracedEnv: it contains any ${VAR} reference, or the
|
|
// whole string is a single bare $VAR shortcut. Embedded bare $VAR
|
|
// references (e.g. "postgres://$USER@host") are deliberately NOT expanded
|
|
// and so do not count. Callers that must persist a literal URL into a
|
|
// context without the shell environment (e.g. a background service) use
|
|
// this to reject env-dependent values. It shares the exact patterns
|
|
// expandBracedEnv uses so the rejection check cannot drift from the
|
|
// expansion semantics.
|
|
func IsEnvDependentURL(s string) bool {
|
|
return bracedEnvPattern.MatchString(s) ||
|
|
bareEnvPattern.MatchString(strings.TrimSpace(s))
|
|
}
|
|
|
|
// bareEnvWarned tracks which bare $VAR names have already been warned
|
|
// about, so each distinct variable triggers a warning at most once.
|
|
var bareEnvWarned sync.Map
|
|
|
|
// ResetBareEnvWarned clears the warning dedup state. Exported for tests.
|
|
func ResetBareEnvWarned() {
|
|
bareEnvWarned.Range(func(k, _ any) bool { bareEnvWarned.Delete(k); return true })
|
|
}
|
|
|
|
// expandBracedEnv expands ${VAR} references in s. As a convenience,
|
|
// if the entire string is a single bare $VAR (e.g. "$PGURL"), it is
|
|
// expanded as a whole-string shortcut. Bare $VAR references embedded
|
|
// in a larger string (e.g. "postgres://$USER@host") are NOT expanded;
|
|
// use ${VAR} syntax instead.
|
|
func expandBracedEnv(s string) (string, error) {
|
|
if parts := bareEnvPattern.FindStringSubmatch(s); parts != nil {
|
|
val, ok := os.LookupEnv(parts[1])
|
|
if !ok {
|
|
return "", fmt.Errorf("environment variable %s is not set", parts[1])
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// Warn about bare $VAR references that won't be expanded.
|
|
if remaining := bracedEnvPattern.ReplaceAllString(s, ""); partialBareEnvPattern.MatchString(remaining) {
|
|
for _, m := range partialBareEnvPattern.FindAllStringSubmatch(remaining, -1) {
|
|
if _, set := os.LookupEnv(m[1]); set {
|
|
if _, warned := bareEnvWarned.LoadOrStore(m[1], true); !warned {
|
|
log.Printf("warning: pg.url contains bare $%s which will NOT be expanded; use ${%s} syntax instead", m[1], m[1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var missingVars []string
|
|
result := bracedEnvPattern.ReplaceAllStringFunc(s, func(match string) string {
|
|
name := bracedEnvPattern.FindStringSubmatch(match)[1]
|
|
val, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
missingVars = append(missingVars, name)
|
|
return ""
|
|
}
|
|
return val
|
|
})
|
|
if len(missingVars) > 0 {
|
|
return "", fmt.Errorf("environment variable(s) not set: %s",
|
|
strings.Join(missingVars, ", "))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// SaveTerminalConfig persists terminal settings to the config file.
|
|
func (c *Config) SaveTerminalConfig(tc TerminalConfig) error {
|
|
return c.withConfigLock(func() error {
|
|
existing, err := c.readConfigMap()
|
|
if err != nil {
|
|
return fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
existing["terminal"] = tc
|
|
if err := c.writeConfigMap(existing); err != nil {
|
|
return err
|
|
}
|
|
c.Terminal = tc
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SaveSettings persists a partial settings update to the config file.
|
|
// The patch map contains config keys mapped to their new values. Only
|
|
// the keys present in patch are written; other config keys are preserved.
|
|
func (c *Config) SaveSettings(patch map[string]any) error {
|
|
return c.withConfigLock(func() error {
|
|
existing, err := c.readConfigMap()
|
|
if err != nil {
|
|
return fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
maps.Copy(existing, patch)
|
|
|
|
// When require_auth is written, remove the legacy
|
|
// remote_access key so it cannot override on next load.
|
|
if _, ok := patch["require_auth"]; ok {
|
|
delete(existing, "remote_access")
|
|
}
|
|
|
|
if err := c.writeConfigMap(existing); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update in-memory config for known keys.
|
|
if v, ok := patch["terminal"]; ok {
|
|
if tc, ok := v.(TerminalConfig); ok {
|
|
c.Terminal = tc
|
|
} else if m, ok := v.(map[string]any); ok {
|
|
if s, ok := m["mode"].(string); ok {
|
|
c.Terminal.Mode = s
|
|
}
|
|
if s, ok := m["custom_bin"].(string); ok {
|
|
c.Terminal.CustomBin = s
|
|
}
|
|
if s, ok := m["custom_args"].(string); ok {
|
|
c.Terminal.CustomArgs = s
|
|
}
|
|
}
|
|
}
|
|
if v, ok := patch["github_token"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
c.GithubToken = s
|
|
}
|
|
}
|
|
if v, ok := patch["auth_token"]; ok {
|
|
if s, ok := v.(string); ok {
|
|
c.AuthToken = s
|
|
}
|
|
}
|
|
if v, ok := patch["require_auth"]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
c.RequireAuth = b
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// EnsureAuthToken generates and persists an auth token if one does
|
|
// not already exist. Called when require_auth is enabled.
|
|
func (c *Config) EnsureAuthToken() error {
|
|
if c.AuthToken != "" {
|
|
return nil
|
|
}
|
|
return c.withConfigLock(func() error {
|
|
existing, err := c.readConfigMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if token, ok := existing["auth_token"].(string); ok && token != "" {
|
|
c.AuthToken = token
|
|
return nil
|
|
}
|
|
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return fmt.Errorf("generating auth token: %w", err)
|
|
}
|
|
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
|
|
existing["auth_token"] = token
|
|
if err := c.writeConfigMap(existing); err != nil {
|
|
return err
|
|
}
|
|
c.AuthToken = token
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// SaveGithubToken persists the GitHub token to the config file.
|
|
func (c *Config) SaveGithubToken(token string) error {
|
|
return c.withConfigLock(func() error {
|
|
existing, err := c.readConfigMap()
|
|
if err != nil {
|
|
return fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
existing["github_token"] = token
|
|
if err := c.writeConfigMap(existing); err != nil {
|
|
return fmt.Errorf("writing config: %w", err)
|
|
}
|
|
c.GithubToken = token
|
|
return nil
|
|
})
|
|
}
|