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:
commit
6aac38fb3b
4 changed files with 676 additions and 0 deletions
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module ody-agent
|
||||
|
||||
go 1.21
|
||||
648
main.go
Normal file
648
main.go
Normal 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
8
proc_other.go
Normal 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
17
proc_windows.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue