303 lines
10 KiB
Go
303 lines
10 KiB
Go
// Package config loads the daemon's runtime configuration. The HTTP daemon is
|
|
// a loopback-only sidecar: it binds 127.0.0.1, takes no public traffic, and
|
|
// reads everything it needs from the environment with sane defaults so it can
|
|
// boot with zero configuration in development.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// LoopbackHost is the only host the daemon ever binds. There is deliberately
|
|
// no AO_HOST env var: the daemon has no auth/CORS/TLS and a stray
|
|
// AO_HOST=0.0.0.0 would turn it into a public no-auth service. If a
|
|
// non-default loopback (e.g. ::1, 127.0.0.2) is ever needed, add it back with
|
|
// an IsLoopback() validator — not a raw env read.
|
|
LoopbackHost = "127.0.0.1"
|
|
// DefaultPort is the single port for REST, terminal mux, health, and control.
|
|
DefaultPort = 3001
|
|
// DefaultRequestTimeout bounds a single REST request. Long-lived terminal mux
|
|
// connections are mounted outside this timeout.
|
|
DefaultRequestTimeout = 60 * time.Second
|
|
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
|
|
// the process exits even if connections are still draining.
|
|
DefaultShutdownTimeout = 10 * time.Second
|
|
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
|
|
// unset. It matches the claude-code adapter's manifest id.
|
|
DefaultAgent = "claude-code"
|
|
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
|
|
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
|
|
DefaultTelemetryPostHogHost = "https://us.i.posthog.com"
|
|
)
|
|
|
|
// TelemetryRemote selects the remote telemetry exporter.
|
|
type TelemetryRemote string
|
|
|
|
const (
|
|
// TelemetryRemoteOff disables remote telemetry export.
|
|
TelemetryRemoteOff TelemetryRemote = "off"
|
|
// TelemetryRemotePostHog exports allowlisted events to PostHog.
|
|
TelemetryRemotePostHog TelemetryRemote = "posthog"
|
|
)
|
|
|
|
// TelemetryConfig controls local and remote telemetry behavior.
|
|
type TelemetryConfig struct {
|
|
Events bool
|
|
Metrics bool
|
|
Remote TelemetryRemote
|
|
PostHogKey string
|
|
PostHogHost string
|
|
}
|
|
|
|
// DefaultAllowedOrigins are the browser origins the daemon's CORS boundary
|
|
// trusts, beyond loopback-served content (which the middleware always trusts —
|
|
// local pages can reach the no-auth daemon directly anyway). The daemon has no
|
|
// auth, so every entry must be an origin web content cannot present:
|
|
// app://renderer is the packaged Electron renderer, served from a custom
|
|
// scheme only the desktop app registers — no website can bear it. The opaque
|
|
// "null" origin (file:// pages, sandboxed iframes on any website) must never
|
|
// be added.
|
|
var DefaultAllowedOrigins = []string{
|
|
"app://renderer",
|
|
}
|
|
|
|
// Config is the fully-resolved daemon configuration. It is immutable once
|
|
// built by Load.
|
|
type Config struct {
|
|
// Host is the bind address. Always loopback — see LoopbackHost.
|
|
Host string
|
|
// Port is the TCP port to bind. The daemon fails fast if it is taken.
|
|
Port int
|
|
// RequestTimeout bounds REST request handling.
|
|
RequestTimeout time.Duration
|
|
// ShutdownTimeout is the hard graceful-shutdown deadline.
|
|
ShutdownTimeout time.Duration
|
|
// RunFilePath is where the PID + port handshake file (running.json) is
|
|
// written so the Electron supervisor can discover and reap the daemon.
|
|
RunFilePath string
|
|
// DataDir is the directory holding durable SQLite state: DB and WAL files.
|
|
// It is created on first use by the storage layer.
|
|
DataDir string
|
|
// Agent is the id of the agent adapter the daemon wires into the Session
|
|
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
|
|
// if no adapter with this id is registered.
|
|
Agent string
|
|
// AllowedOrigins are the browser origins granted CORS read access (see
|
|
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
|
|
AllowedOrigins []string
|
|
// Telemetry controls local/remote telemetry sinks.
|
|
Telemetry TelemetryConfig
|
|
}
|
|
|
|
// Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so
|
|
// the result is correct for IPv6 literals as well as IPv4 / hostnames.
|
|
func (c Config) Addr() string {
|
|
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
|
}
|
|
|
|
// Load resolves configuration from the environment, applying defaults. It
|
|
// returns an error only for values that are present but malformed (e.g. a
|
|
// non-numeric AO_PORT); missing values fall back to defaults.
|
|
//
|
|
// Recognised variables:
|
|
//
|
|
// AO_PORT bind port (default 3001)
|
|
// AO_REQUEST_TIMEOUT per-request timeout (Go duration > 0, default 60s)
|
|
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
|
|
// AO_RUN_FILE running.json path (default ~/.ao/running.json)
|
|
// AO_DATA_DIR durable state dir (default ~/.ao/data)
|
|
// AO_AGENT agent adapter id (default claude-code)
|
|
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
|
|
// AO_TELEMETRY_EVENTS local event capture off|on (default off)
|
|
// AO_TELEMETRY_METRICS local metric capture off|on (default off)
|
|
// AO_TELEMETRY_REMOTE remote exporter off|posthog (default off)
|
|
// AO_TELEMETRY_POSTHOG_KEY PostHog project key
|
|
// AO_TELEMETRY_POSTHOG_HOST PostHog host (default DefaultTelemetryPostHogHost)
|
|
//
|
|
// The bind host is not configurable: the daemon is loopback-only by design.
|
|
func Load() (Config, error) {
|
|
cfg := Config{
|
|
Host: LoopbackHost,
|
|
Port: DefaultPort,
|
|
RequestTimeout: DefaultRequestTimeout,
|
|
ShutdownTimeout: DefaultShutdownTimeout,
|
|
Agent: DefaultAgent,
|
|
AllowedOrigins: DefaultAllowedOrigins,
|
|
Telemetry: TelemetryConfig{
|
|
Remote: TelemetryRemoteOff,
|
|
PostHogHost: DefaultTelemetryPostHogHost,
|
|
},
|
|
}
|
|
|
|
if raw := os.Getenv("AO_PORT"); raw != "" {
|
|
port, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("invalid AO_PORT %q: %w", raw, err)
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return Config{}, fmt.Errorf("invalid AO_PORT %d: out of range 1-65535", port)
|
|
}
|
|
cfg.Port = port
|
|
}
|
|
|
|
if raw := os.Getenv("AO_REQUEST_TIMEOUT"); raw != "" {
|
|
d, err := parsePositiveDuration("AO_REQUEST_TIMEOUT", raw)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RequestTimeout = d
|
|
}
|
|
|
|
if raw := os.Getenv("AO_SHUTDOWN_TIMEOUT"); raw != "" {
|
|
d, err := parsePositiveDuration("AO_SHUTDOWN_TIMEOUT", raw)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ShutdownTimeout = d
|
|
}
|
|
|
|
if raw := os.Getenv("AO_AGENT"); raw != "" {
|
|
cfg.Agent = raw
|
|
}
|
|
|
|
if raw, ok := os.LookupEnv("AO_ALLOWED_ORIGINS"); ok && raw != "" {
|
|
// Explicit override replaces the defaults entirely so a deployment can
|
|
// also narrow the list. The "null" origin is rejected, never silently
|
|
// dropped: an operator allowing it would open the no-auth daemon to
|
|
// every sandboxed iframe on the web.
|
|
origins := make([]string, 0, 4)
|
|
for _, origin := range strings.Split(raw, ",") {
|
|
origin = strings.TrimSpace(origin)
|
|
if origin == "" {
|
|
continue
|
|
}
|
|
if origin == "null" || origin == "*" {
|
|
return Config{}, fmt.Errorf("invalid AO_ALLOWED_ORIGINS entry %q: wildcard and null origins are not allowed", origin)
|
|
}
|
|
origins = append(origins, origin)
|
|
}
|
|
cfg.AllowedOrigins = origins
|
|
}
|
|
|
|
if raw := os.Getenv("AO_TELEMETRY_EVENTS"); raw != "" {
|
|
v, err := parseToggleEnv("AO_TELEMETRY_EVENTS", raw)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Telemetry.Events = v
|
|
}
|
|
if raw := os.Getenv("AO_TELEMETRY_METRICS"); raw != "" {
|
|
v, err := parseToggleEnv("AO_TELEMETRY_METRICS", raw)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Telemetry.Metrics = v
|
|
}
|
|
if raw := os.Getenv("AO_TELEMETRY_REMOTE"); raw != "" {
|
|
remote, err := parseTelemetryRemote(raw)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("invalid AO_TELEMETRY_REMOTE %q: %w", raw, err)
|
|
}
|
|
cfg.Telemetry.Remote = remote
|
|
}
|
|
if raw := os.Getenv("AO_TELEMETRY_POSTHOG_KEY"); raw != "" {
|
|
cfg.Telemetry.PostHogKey = raw
|
|
}
|
|
if raw := os.Getenv("AO_TELEMETRY_POSTHOG_HOST"); raw != "" {
|
|
cfg.Telemetry.PostHogHost = raw
|
|
}
|
|
|
|
runFile, err := resolveRunFilePath()
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RunFilePath = runFile
|
|
|
|
dataDir, err := resolveDataDir()
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.DataDir = dataDir
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func parseToggleEnv(name, raw string) (bool, error) {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "on", "true", "1", "yes":
|
|
return true, nil
|
|
case "off", "false", "0", "no":
|
|
return false, nil
|
|
default:
|
|
return false, fmt.Errorf("%s must be off|on", name)
|
|
}
|
|
}
|
|
|
|
func parseTelemetryRemote(raw string) (TelemetryRemote, error) {
|
|
switch TelemetryRemote(strings.ToLower(strings.TrimSpace(raw))) {
|
|
case TelemetryRemoteOff:
|
|
return TelemetryRemoteOff, nil
|
|
case TelemetryRemotePostHog:
|
|
return TelemetryRemotePostHog, nil
|
|
default:
|
|
return "", fmt.Errorf("must be off|posthog")
|
|
}
|
|
}
|
|
|
|
// parsePositiveDuration rejects zero and negative durations: a zero
|
|
// RequestTimeout would expire every request instantly, and a non-positive
|
|
// ShutdownTimeout would defeat graceful shutdown.
|
|
func parsePositiveDuration(name, raw string) (time.Duration, error) {
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s %q: %w", name, raw, err)
|
|
}
|
|
if d <= 0 {
|
|
return 0, fmt.Errorf("invalid %s %q: must be > 0", name, raw)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE
|
|
// wins; otherwise it sits under the canonical AO home directory so the CLI and
|
|
// Electron supervisor share one handshake location.
|
|
func resolveRunFilePath() (string, error) {
|
|
if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" {
|
|
return p, nil
|
|
}
|
|
stateDir, err := defaultStateDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(stateDir, "running.json"), nil
|
|
}
|
|
|
|
// resolveDataDir picks where durable state (the SQLite DB) lives. An explicit
|
|
// AO_DATA_DIR wins; otherwise it defaults under the same canonical AO home
|
|
// directory as the run-file.
|
|
func resolveDataDir() (string, error) {
|
|
if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" {
|
|
return p, nil
|
|
}
|
|
stateDir, err := defaultStateDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(stateDir, "data"), nil
|
|
}
|
|
|
|
func defaultStateDir() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve state dir: %w", err)
|
|
}
|
|
return filepath.Join(homeDir, ".ao"), nil
|
|
}
|