// 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" "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" ) // 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 } // 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 /running.json) // AO_DATA_DIR durable state dir (default /data) // AO_AGENT agent adapter id (default claude-code) // // 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, } 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 } 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 } // 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 per-user config directory so multiple repos // share one supervisor handshake location. func resolveRunFilePath() (string, error) { if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" { return p, nil } dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("resolve state dir: %w", err) } return filepath.Join(dir, "agent-orchestrator", "running.json"), nil } // resolveDataDir picks where durable state (the SQLite DB) lives. An explicit // AO_DATA_DIR wins; otherwise it sits under the per-user config directory // alongside running.json. func resolveDataDir() (string, error) { if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" { return p, nil } dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("resolve state dir: %w", err) } return filepath.Join(dir, "agent-orchestrator", "data"), nil }