// Ody Agent — personal desktop client for WellSpr.ing / NNN.today // // Runs as a background daemon (no UI window). On Windows it self-installs // to Task Scheduler on first run. On macOS via LaunchAgent. On Linux via // systemd user service. Zero prompts — fully silent install. // // Config file: ~/.ody-agent/config.env (KEY=value lines) // AGENT_KEY — required: personal key from NNN.today/client // SERVER_URL — optional: override default // POLL_INTERVAL — optional: seconds between polls (default 30) // // First-run flow (no key configured): // 1. Registers itself to auto-start at every login (Task Scheduler / LaunchAgent / systemd) // 2. Opens the browser to /client // 3. Shows a notification: "Paste your key at /client to activate" // 4. Watches config.env for up to 10 minutes — as soon as AGENT_KEY appears, starts polling // // Build: // Windows: GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w -H=windowsgui" -o ody-agent-win.exe . // macOS: GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-s -w" -o ody-agent-mac . // Linux: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o ody-agent-linux . package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // isSafeURL enforces URL scheme + domain policy before the agent acts on a URL. // Only https/http pointing at *.today or wellspr.ing domains are accepted. // This is defense-in-depth — the server enforces the same policy before queuing. func isSafeURL(raw string) bool { if raw == "" { return false } u, err := url.Parse(raw) if err != nil { return false } scheme := strings.ToLower(u.Scheme) if scheme != "https" { return false } host := strings.ToLower(u.Hostname()) trustedSuffixes := []string{ ".today", "wellspr.ing", "425.today", "wellspring.ing", } for _, suffix := range trustedSuffixes { if host == suffix || strings.HasSuffix(host, "."+suffix) || strings.HasSuffix(host, suffix) { return true } } return false } // ── Build-time defaults (overridden via -ldflags at CI) ──────────────────── var defaultServer = "https://425.today" // ── Config ──────────────────────────────────────────────────────────────────── const ( defaultInterval = 30 * time.Second appName = "Ody" configFilename = "config.env" logFilename = "ody-agent.log" ) type Config struct { AgentKey string ServerURL string PollInterval time.Duration } func configDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, ".ody-agent") } func loadConfig() Config { cfg := Config{ ServerURL: defaultServer, PollInterval: defaultInterval, } // Environment variables take priority if v := os.Getenv("AGENT_KEY"); v != "" { cfg.AgentKey = v } if v := os.Getenv("SERVER_URL"); v != "" { cfg.ServerURL = strings.TrimRight(v, "/") } if v := os.Getenv("POLL_INTERVAL"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.PollInterval = time.Duration(n) * time.Second } } // Config file (lower priority than env vars) cfgPath := filepath.Join(configDir(), configFilename) if data, err := os.ReadFile(cfgPath); err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) switch k { case "AGENT_KEY": if cfg.AgentKey == "" { cfg.AgentKey = v } case "SERVER_URL": if cfg.ServerURL == defaultServer { cfg.ServerURL = strings.TrimRight(v, "/") } case "POLL_INTERVAL": if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.PollInterval = time.Duration(n) * time.Second } } } } return cfg } // ── Native notifications (pure Go, no CGO) ──────────────────────────────────── func notify(title, body string) { switch runtime.GOOS { case "windows": script := fmt.Sprintf( `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; `+ `$t = [Windows.UI.Notifications.ToastTemplateType]::ToastText02; `+ `$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($t); `+ `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('%s')) | Out-Null; `+ `$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode('%s')) | Out-Null; `+ `$notif = [Windows.UI.Notifications.ToastNotification]::new($xml); `+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Ody Agent').Show($notif)`, escapePS(title), escapePS(body), ) cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script) noWindow(cmd) cmd.Start() case "darwin": exec.Command("osascript", "-e", fmt.Sprintf(`display notification %q with title %q`, body, title), ).Start() default: exec.Command("notify-send", "-i", "dialog-information", title, body).Start() } } func escapePS(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, "'", "''"), "`", "``") } // ── Browser open ────────────────────────────────────────────────────────────── func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) noWindow(cmd) case "darwin": cmd = exec.Command("open", url) default: cmd = exec.Command("xdg-open", url) } cmd.Start() } // ── Self-installation (no prompts) ──────────────────────────────────────────── // selfExePath returns the absolute path to the running executable. func selfExePath() string { exe, err := os.Executable() if err != nil { return "" } resolved, err := filepath.EvalSymlinks(exe) if err != nil { return exe } return resolved } // installSelf registers the agent for auto-start at login using the OS-native // mechanism. It is idempotent — safe to call on every startup. func installSelf(cfg Config) { exePath := selfExePath() if exePath == "" { log.Printf("[install] could not determine own path — skipping auto-start registration") return } switch runtime.GOOS { case "windows": installSelfWindows(exePath, cfg) case "darwin": installSelfDarwin(exePath, cfg) default: installSelfLinux(exePath, cfg) } } func installSelfWindows(exePath string, cfg Config) { taskName := "OdyAgent-425today" // Build the scheduled task action psCmd := fmt.Sprintf( `$env:AGENT_KEY = (Get-Content '%s' -Raw | Select-String 'AGENT_KEY=(.+)').Matches.Groups[1].Value.Trim(); `+ `& '%s'`, filepath.Join(configDir(), configFilename), exePath, ) script := fmt.Sprintf( `$a = New-ScheduledTaskAction -Execute '%s'; `+ `$t = New-ScheduledTaskTrigger -AtLogOn; `+ `$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartOnIdle $false; `+ `$p = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Limited -LogonType Interactive; `+ `Register-ScheduledTask -TaskName '%s' -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null`, escapePS(exePath), escapePS(taskName), ) _ = psCmd // kept for reference installCmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script) noWindow(installCmd) out, err := installCmd.CombinedOutput() if err != nil { log.Printf("[install] Task Scheduler registration failed: %v — %s", err, strings.TrimSpace(string(out))) } else { log.Printf("[install] Task Scheduler: '%s' registered for %s", taskName, exePath) } } func installSelfDarwin(exePath string, cfg Config) { plistDir := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents") plistPath := filepath.Join(plistDir, "ing.wellspr.ody-agent.plist") _ = os.MkdirAll(plistDir, 0755) cfgEnv := filepath.Join(configDir(), configFilename) plist := fmt.Sprintf(` Labeling.wellspr.ody-agent ProgramArguments %s EnvironmentVariables ODY_CONFIG%s RunAtLoad KeepAlive StandardOutPath%s StandardErrorPath%s `, exePath, cfgEnv, filepath.Join(configDir(), logFilename), filepath.Join(configDir(), logFilename)) if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil { log.Printf("[install] LaunchAgent write failed: %v", err) return } exec.Command("launchctl", "unload", plistPath).Run() if err := exec.Command("launchctl", "load", "-w", plistPath).Run(); err != nil { log.Printf("[install] launchctl load failed: %v", err) } else { log.Printf("[install] LaunchAgent registered: %s", plistPath) } } func installSelfLinux(exePath string, cfg Config) { svcDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user") svcPath := filepath.Join(svcDir, "ody-agent.service") _ = os.MkdirAll(svcDir, 0755) unit := fmt.Sprintf(`[Unit] Description=Ody Desktop Agent After=network.target [Service] Type=simple ExecStart=%s Restart=on-failure RestartSec=30 [Install] WantedBy=default.target `, exePath) if err := os.WriteFile(svcPath, []byte(unit), 0644); err != nil { log.Printf("[install] systemd unit write failed: %v", err) return } exec.Command("systemctl", "--user", "daemon-reload").Run() if err := exec.Command("systemctl", "--user", "enable", "--now", "ody-agent").Run(); err != nil { log.Printf("[install] systemd enable failed: %v", err) } else { log.Printf("[install] systemd user service enabled: %s", svcPath) } } // ── Key persistence ─────────────────────────────────────────────────────────── // saveKey writes (or replaces) the AGENT_KEY line in config.env. // Existing lines for other settings are preserved. func saveKey(key string) error { dir := configDir() if err := os.MkdirAll(dir, 0700); err != nil { return err } cfgPath := filepath.Join(dir, configFilename) // Preserve existing non-key lines kept := []string{} if data, err := os.ReadFile(cfgPath); err == nil { for _, l := range strings.Split(string(data), "\n") { l2 := strings.TrimSpace(l) if l2 == "" || strings.HasPrefix(l2, "AGENT_KEY=") { continue } kept = append(kept, l) } } content := "AGENT_KEY=" + key + "\n" if len(kept) > 0 { content += strings.Join(kept, "\n") + "\n" } return os.WriteFile(cfgPath, []byte(content), 0600) } // autoClaim attempts to retrieve an agent key from the server using the // machine's outbound IP as the matching factor. The server will have // recorded the browser's IP when the calling interview completed — if this // EXE is running on the same machine (same IP) within the 2-hour claim // window, the server returns the pre-issued key and voids the claim slot. // Returns the key string or "" if no claimable key exists. func autoClaim(serverURL string) string { url := serverURL + "/api/v1/agent/auto-claim" resp, err := httpClient.Get(url) if err != nil { log.Printf("[auto-claim] request failed: %v", err) return "" } defer resp.Body.Close() if resp.StatusCode != 200 { log.Printf("[auto-claim] no claimable key (status %d)", resp.StatusCode) return "" } body, _ := io.ReadAll(resp.Body) var result struct { Key string `json:"key"` Label string `json:"label"` CovenantName string `json:"covenant_name"` } if err := json.Unmarshal(body, &result); err != nil || result.Key == "" { log.Printf("[auto-claim] bad response: %v", err) return "" } log.Printf("[auto-claim] key claimed automatically (label: %s)", result.Label) return result.Key } // waitForKey polls config.env every 15 seconds for up to maxWait, returning as // soon as an AGENT_KEY line appears. Returns the key or "" on timeout. func waitForKey(maxWait time.Duration) string { deadline := time.Now().Add(maxWait) cfgPath := filepath.Join(configDir(), configFilename) for time.Now().Before(deadline) { time.Sleep(15 * time.Second) if data, err := os.ReadFile(cfgPath); err == nil { for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "AGENT_KEY=") { k := strings.TrimPrefix(line, "AGENT_KEY=") k = strings.TrimSpace(k) if k != "" { return k } } } } } return "" } // ── Logging ─────────────────────────────────────────────────────────────────── func setupLogging() { dir := configDir() _ = os.MkdirAll(dir, 0700) logPath := filepath.Join(dir, logFilename) f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err == nil { log.SetOutput(io.MultiWriter(os.Stderr, f)) } log.SetFlags(log.LstdFlags | log.Lshortfile) } // ── HTTP helpers ────────────────────────────────────────────────────────────── var httpClient = &http.Client{Timeout: 15 * time.Second} // Task is a personal job returned by the platform. type Task struct { ID int `json:"id"` Type string `json:"type"` Payload json.RawMessage `json:"payload"` } // Payload types type NotifyPayload struct { Title string `json:"title"` Body string `json:"body"` } type OpenURLPayload struct { URL string `json:"url"` Title string `json:"title,omitempty"` } // MessagePayload is an Ody-initiated chat nudge. // The agent shows a native notification. The URL (if provided) is a // deep-link to the user's calling session — clicking opens the browser. type MessagePayload struct { Body string `json:"body"` URL string `json:"url,omitempty"` // deeplink to open on click SessionID string `json:"session_id,omitempty"` // informational } func pollNextTask(cfg Config) (*Task, error) { // Personal queue — per-user, separate from civic data crawl jobs. // Key is sent in X-Agent-Key header (not query param) to keep it out of server logs. url := fmt.Sprintf("%s/api/agent/personal/next", cfg.ServerURL) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("X-Agent-Key", cfg.AgentKey) resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == 204 { return nil, nil // queue empty } if resp.StatusCode != 200 { return nil, fmt.Errorf("poll status %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) var task Task if err := json.Unmarshal(body, &task); err != nil { return nil, err } return &task, nil } func reportResult(cfg Config, taskID int, success bool, output string) { data, _ := json.Marshal(map[string]any{"success": success, "output": output}) url := fmt.Sprintf("%s/api/agent/personal/%d/result", cfg.ServerURL, taskID) req, _ := http.NewRequest("POST", url, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Agent-Key", cfg.AgentKey) resp, err := httpClient.Do(req) if err != nil { log.Printf("[result] error: %v", err) return } resp.Body.Close() } // ── Task executor ───────────────────────────────────────────────────────────── func executeTask(cfg Config, task *Task) { log.Printf("[task] id=%d type=%s", task.ID, task.Type) switch task.Type { case "notify", "notification", "alert": var p NotifyPayload if err := json.Unmarshal(task.Payload, &p); err != nil { reportResult(cfg, task.ID, false, err.Error()) return } title := p.Title if title == "" { title = appName } notify(title, p.Body) reportResult(cfg, task.ID, true, "notified") case "open_url": var p OpenURLPayload if err := json.Unmarshal(task.Payload, &p); err != nil { reportResult(cfg, task.ID, false, err.Error()) return } if p.URL == "" { reportResult(cfg, task.ID, false, "missing url") return } if !isSafeURL(p.URL) { log.Printf("[task] open_url REJECTED — URL fails policy: %s", p.URL) reportResult(cfg, task.ID, false, "url rejected by agent policy") return } openBrowser(p.URL) label := p.Title if label == "" { label = p.URL } notify(appName, "Opening: "+label) reportResult(cfg, task.ID, true, "opened") case "message": // Ody-initiated conversation nudge. // Show a native notification. If a deep-link URL is present, open it. var p MessagePayload if err := json.Unmarshal(task.Payload, &p); err != nil { reportResult(cfg, task.ID, false, err.Error()) return } notify(appName, p.Body) if p.URL != "" { if !isSafeURL(p.URL) { log.Printf("[task] message URL REJECTED — fails policy: %s", p.URL) // Still deliver the notification; just don't open the URL reportResult(cfg, task.ID, true, "delivered (url rejected)") return } // Small delay so the notification appears before the browser opens time.Sleep(600 * time.Millisecond) openBrowser(p.URL) } reportResult(cfg, task.ID, true, "delivered") default: log.Printf("[task] unknown type: %s", task.Type) reportResult(cfg, task.ID, false, "unknown task type: "+task.Type) } } // ── Main ────────────────────────────────────────────────────────────────────── func main() { setupLogging() cfg := loadConfig() // ── First-run: zero-config bootstrap ───────────────────────────────────── // If no key is in config.env, attempt auto-claim first. The server matches // this machine's outbound IP against keys issued within the last 2 hours — // if the user just completed their calling interview on this machine, the // key is returned silently with no user interaction at all. // // If auto-claim fails (different IP, expired window, key already claimed), // fall back: register for auto-start, open /client in the browser, show a // notification, then watch config.env for up to 10 minutes. if cfg.AgentKey == "" { log.Printf("[agent] no key — attempting auto-claim from server") installSelf(cfg) if key := autoClaim(cfg.ServerURL); key != "" { if err := saveKey(key); err != nil { log.Printf("[agent] could not save auto-claimed key: %v", err) } else { cfg = loadConfig() notify(appName, "Ody connected — your personal agent is active.") log.Printf("[agent] auto-claim succeeded — key saved, starting poll loop") } } if cfg.AgentKey == "" { // Auto-claim didn't work — guide the user to /client manually clientURL := cfg.ServerURL + "/client" openBrowser(clientURL) notify(appName, fmt.Sprintf("Visit %s, sign in, and click 'Download config.env'. Drop that file in ~/.ody-agent/ — Ody will pick it up automatically.", clientURL)) log.Printf("[agent] waiting up to 10 minutes for AGENT_KEY to appear in config.env…") key := waitForKey(10 * time.Minute) if key == "" { log.Printf("[agent] no key after 10 minutes — exiting. Agent will retry at next login.") return } cfg = loadConfig() log.Printf("[agent] key found via config watch — starting poll loop") } } // ── Ensure auto-start is registered on every launch ────────────────────── // This is idempotent — safe even when already registered. installSelf(cfg) keyDisplay := "(none)" if len(cfg.AgentKey) >= 8 { keyDisplay = cfg.AgentKey[:4] + "…" + cfg.AgentKey[len(cfg.AgentKey)-4:] } else if cfg.AgentKey != "" { keyDisplay = "***" } log.Printf("[agent] starting — server=%s interval=%s key=%s", cfg.ServerURL, cfg.PollInterval, keyDisplay) notify(appName, "Ody is watching — ready to surface what matters to you.") ticker := time.NewTicker(cfg.PollInterval) defer ticker.Stop() consecutiveErrors := 0 poll := func() { task, err := pollNextTask(cfg) if err != nil { consecutiveErrors++ log.Printf("[poll] error #%d: %v", consecutiveErrors, err) if consecutiveErrors == 5 { notify(appName, "Having trouble reaching the server — will keep trying.") } return } consecutiveErrors = 0 if task != nil { executeTask(cfg, task) } } // Poll immediately on start poll() for range ticker.C { poll() } }