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 .
This commit is contained in:
Ody WellSpr.ing 2026-04-24 01:38:20 +00:00
commit 6aac38fb3b
4 changed files with 676 additions and 0 deletions

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module ody-agent
go 1.21

648
main.go Normal file
View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ing.wellspr.ody-agent</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>ODY_CONFIG</key><string>%s</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><false/>
<key>StandardOutPath</key><string>%s</string>
<key>StandardErrorPath</key><string>%s</string>
</dict>
</plist>`, 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()
}
}

8
proc_other.go Normal file
View file

@ -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) {}

17
proc_windows.go Normal file
View file

@ -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
}
}