From 6aac38fb3be8703771279fe9a664de32e82acae0 Mon Sep 17 00:00:00 2001 From: "Ody WellSpr.ing" Date: Fri, 24 Apr 2026 01:38:20 +0000 Subject: [PATCH] Initial commit: Ody Agent v1.0.0 Cross-platform desktop daemon for WellSpr.ing / NNN.today merchants. Runs silently at login on Windows (Task Scheduler), macOS (LaunchAgent), and Linux (systemd user service). Build commands: 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 . --- go.mod | 3 + main.go | 648 ++++++++++++++++++++++++++++++++++++++++++++++++ proc_other.go | 8 + proc_windows.go | 17 ++ 4 files changed, 676 insertions(+) create mode 100644 go.mod create mode 100644 main.go create mode 100644 proc_other.go create mode 100644 proc_windows.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d93ff4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ody-agent + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..2bc878d --- /dev/null +++ b/main.go @@ -0,0 +1,648 @@ +// 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() + } +} diff --git a/proc_other.go b/proc_other.go new file mode 100644 index 0000000..29e28d2 --- /dev/null +++ b/proc_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import "os/exec" + +// noWindow is a no-op on non-Windows platforms. +func noWindow(cmd *exec.Cmd) {} diff --git a/proc_windows.go b/proc_windows.go new file mode 100644 index 0000000..93580a0 --- /dev/null +++ b/proc_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +// noWindow sets CREATE_NO_WINDOW on the command so that spawning powershell, +// rundll32, or any other subprocess never flashes a console/DOS window on +// the user's desktop. Must be called before cmd.Start(). +func noWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } +}