From 05b1930e04ac222d73ffb2f45c1b1febb69f893d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 28 Jan 2026 22:19:28 -1000 Subject: Add Agent Context API for external agent integration Phase 1: Authentication and read-only context - POST /agent/auth/request - request access with name + agent_id - GET /agent/auth/poll - poll for approval status - POST /agent/auth/approve|deny - user approval (browser auth required) - GET /agent/context - 7-day timeline context (agent session required) Phase 1.5: Browser-only agent endpoints (HTML pages) - GET /agent/web/request - request page with token - GET /agent/web/status - status page with polling - GET /agent/web/context - context page with timeline data WebSocket notifications: - GET /ws/notifications - push agent requests to browsers - Approval modal with trust indicators and countdown timer Database: - agents table for registered agent tracking - agent_sessions table for pending/active sessions Co-Authored-By: Claude Opus 4.5 --- cmd/dashboard/main.go | 33 ++++ go.mod | 1 + go.sum | 2 + internal/auth/middleware.go | 9 +- internal/handlers/handlers_test.go | 9 +- internal/middleware/security.go | 2 +- internal/models/types.go | 51 ++++++ internal/store/sqlite.go | 345 +++++++++++++++++++++++++++++++++++++ migrations/010_agent_tables.sql | 30 ++++ web/static/js/app.js | 175 +++++++++++++++++++ web/templates/agent-context.html | 121 +++++++++++++ web/templates/agent-error.html | 28 +++ web/templates/agent-request.html | 61 +++++++ web/templates/agent-status.html | 80 +++++++++ 14 files changed, 941 insertions(+), 6 deletions(-) create mode 100644 migrations/010_agent_tables.sql create mode 100644 web/templates/agent-context.html create mode 100644 web/templates/agent-error.html create mode 100644 web/templates/agent-request.html create mode 100644 web/templates/agent-status.html diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index ce91e6e..8f87e30 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -136,6 +136,9 @@ func main() { // Rate limiter for auth endpoints authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow) + // Rate limiter for agent auth (stricter - 10 requests/minute per IP) + agentAuthRateLimiter := appmiddleware.NewRateLimiter(10, time.Minute) + // Public routes (no auth required) r.Get("/login", authHandlers.HandleLoginPage) r.With(authRateLimiter.Limit).Post("/login", authHandlers.HandleLogin) @@ -148,6 +151,33 @@ func main() { // Conditions page (public - no auth required) r.Get("/conditions", h.HandleConditionsPage) + // Agent API + r.Route("/agent", func(r chi.Router) { + // Public endpoints (no browser auth, but rate limited) + r.With(agentAuthRateLimiter.Limit).Post("/auth/request", h.HandleAgentAuthRequest) + r.Get("/auth/poll", h.HandleAgentAuthPoll) + + // Browser auth required for approve/deny + r.Group(func(r chi.Router) { + r.Use(authHandlers.Middleware().RequireAuth) + r.Post("/auth/approve", h.HandleAgentAuthApprove) + r.Post("/auth/deny", h.HandleAgentAuthDeny) + }) + + // Agent session required for context + r.Group(func(r chi.Router) { + r.Use(h.AgentAuthMiddleware) + r.Get("/context", h.HandleAgentContext) + }) + + // HTML endpoints for browser-only agents (GET requests only) + r.Route("/web", func(r chi.Router) { + r.With(agentAuthRateLimiter.Limit).Get("/request", h.HandleAgentWebRequest) + r.Get("/status", h.HandleAgentWebStatus) + r.Get("/context", h.HandleAgentWebContext) + }) + }) + // Protected routes (auth required) r.Group(func(r chi.Router) { r.Use(authHandlers.Middleware().RequireAuth) @@ -201,6 +231,9 @@ func main() { // Shopping mode (focused single-store view) r.Get("/shopping/mode/{store}", h.HandleShoppingMode) r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle) + + // WebSocket for notifications + r.Get("/ws/notifications", h.HandleWebSocket) }) // Start server diff --git a/go.mod b/go.mod index d9bba6f..df85101 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/go.sum b/go.sum index e05da29..b34a475 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index ecdde82..78f3b53 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "encoding/base64" "net/http" + "strings" "github.com/alexedwards/scs/v2" ) @@ -63,6 +64,12 @@ func (m *Middleware) ClearSession(r *http.Request) error { // CSRFProtect checks for a valid CSRF token on state-changing requests func (m *Middleware) CSRFProtect(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip CSRF for agent API endpoints (they use token-based auth, not cookies) + if strings.HasPrefix(r.URL.Path, "/agent/") { + next.ServeHTTP(w, r) + return + } + // Ensure a token exists in the session if !m.sessions.Exists(r.Context(), SessionKeyCSRF) { token, err := generateToken() @@ -78,7 +85,7 @@ func (m *Middleware) CSRFProtect(next http.Handler) http.Handler { // Check token for state-changing methods if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH" { requestToken := r.Header.Get("X-CSRF-Token") - + if requestToken == "" { requestToken = r.FormValue("csrf_token") } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index d863546..3367ef6 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -75,10 +75,11 @@ func loadTestTemplates(t *testing.T) *template.Template { } } - // Parse partials - tmpl, err = tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html")) - if err != nil { - tmpl, _ = tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html")) + // Parse partials - don't reassign tmpl if parsing fails + if parsed, err := tmpl.ParseGlob(filepath.Join("web", "templates", "partials", "*.html")); err == nil { + tmpl = parsed + } else if parsed, err := tmpl.ParseGlob(filepath.Join("..", "..", "web", "templates", "partials", "*.html")); err == nil { + tmpl = parsed } return tmpl diff --git a/internal/middleware/security.go b/internal/middleware/security.go index e048645..8d1e619 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -29,7 +29,7 @@ func SecurityHeaders(debug bool) func(http.Handler) http.Handler { "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "+ "font-src 'self' https://fonts.gstatic.com; "+ "frame-src https://www.youtube.com https://embed.windy.com; "+ - "connect-src 'self'") + "connect-src 'self' wss: ws:") next.ServeHTTP(w, r) }) diff --git a/internal/models/types.go b/internal/models/types.go index 4bf8462..5214bf8 100644 --- a/internal/models/types.go +++ b/internal/models/types.go @@ -146,3 +146,54 @@ type DashboardData struct { LastUpdated time.Time `json:"last_updated"` Errors []string `json:"errors,omitempty"` } + +// Agent represents a registered external agent +type Agent struct { + ID int64 `json:"id"` + Name string `json:"name"` + AgentID string `json:"agent_id"` // UUID from agent + CreatedAt time.Time `json:"created_at"` + LastSeen *time.Time `json:"last_seen,omitempty"` + Trusted bool `json:"trusted"` +} + +// AgentSession represents a pending request or active session +type AgentSession struct { + ID int64 `json:"id"` + RequestToken string `json:"request_token"` + AgentName string `json:"agent_name"` + AgentID string `json:"agent_id"` + Status string `json:"status"` // pending, approved, denied, expired + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + SessionToken string `json:"session_token,omitempty"` + SessionExpiresAt *time.Time `json:"session_expires_at,omitempty"` +} + +// AgentAuthRequest is the request body for agent auth +type AgentAuthRequest struct { + Name string `json:"name"` + AgentID string `json:"agent_id"` +} + +// AgentAuthResponse is the response for auth request +type AgentAuthResponse struct { + RequestToken string `json:"request_token"` + Status string `json:"status"` +} + +// AgentPollResponse is the response for poll endpoint +type AgentPollResponse struct { + Status string `json:"status"` + SessionToken string `json:"session_token,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// AgentTrustLevel indicates the trust state of an agent +type AgentTrustLevel string + +const ( + AgentTrustNew AgentTrustLevel = "new" + AgentTrustRecognized AgentTrustLevel = "recognized" + AgentTrustSuspicious AgentTrustLevel = "suspicious" +) diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 396ac54..b324e9f 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -796,3 +796,348 @@ func (s *Store) GetCardsByDateRange(start, end time.Time) ([]models.Card, error) return cards, rows.Err() } + +// Agent operations + +// CreateAgentSession creates a new pending agent session +func (s *Store) CreateAgentSession(session *models.AgentSession) error { + result, err := s.db.Exec(` + INSERT INTO agent_sessions (request_token, agent_name, agent_id, status, expires_at) + VALUES (?, ?, ?, 'pending', ?) + `, session.RequestToken, session.AgentName, session.AgentID, session.ExpiresAt) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + session.ID = id + return nil +} + +// GetAgentSessionByRequestToken retrieves a session by request token +func (s *Store) GetAgentSessionByRequestToken(token string) (*models.AgentSession, error) { + var session models.AgentSession + var sessionToken sql.NullString + var sessionExpiresAt sql.NullTime + + err := s.db.QueryRow(` + SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at, session_token, session_expires_at + FROM agent_sessions + WHERE request_token = ? + `, token).Scan( + &session.ID, + &session.RequestToken, + &session.AgentName, + &session.AgentID, + &session.Status, + &session.CreatedAt, + &session.ExpiresAt, + &sessionToken, + &sessionExpiresAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + if sessionToken.Valid { + session.SessionToken = sessionToken.String + } + if sessionExpiresAt.Valid { + session.SessionExpiresAt = &sessionExpiresAt.Time + } + return &session, nil +} + +// GetPendingAgentSessionByAgentID retrieves an existing pending session for an agent +func (s *Store) GetPendingAgentSessionByAgentID(agentID string) (*models.AgentSession, error) { + var session models.AgentSession + + err := s.db.QueryRow(` + SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at + FROM agent_sessions + WHERE agent_id = ? AND status = 'pending' AND expires_at > datetime('now', 'localtime') + ORDER BY created_at DESC + LIMIT 1 + `, agentID).Scan( + &session.ID, + &session.RequestToken, + &session.AgentName, + &session.AgentID, + &session.Status, + &session.CreatedAt, + &session.ExpiresAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +// GetAgentSessionBySessionToken retrieves a session by session token +func (s *Store) GetAgentSessionBySessionToken(token string) (*models.AgentSession, error) { + var session models.AgentSession + var sessionToken sql.NullString + var sessionExpiresAt sql.NullTime + + err := s.db.QueryRow(` + SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at, session_token, session_expires_at + FROM agent_sessions + WHERE session_token = ? AND status = 'approved' + `, token).Scan( + &session.ID, + &session.RequestToken, + &session.AgentName, + &session.AgentID, + &session.Status, + &session.CreatedAt, + &session.ExpiresAt, + &sessionToken, + &sessionExpiresAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + if sessionToken.Valid { + session.SessionToken = sessionToken.String + } + if sessionExpiresAt.Valid { + session.SessionExpiresAt = &sessionExpiresAt.Time + } + return &session, nil +} + +// ApproveAgentSession approves a pending session +func (s *Store) ApproveAgentSession(requestToken, sessionToken string, sessionExpiresAt time.Time) error { + result, err := s.db.Exec(` + UPDATE agent_sessions + SET status = 'approved', session_token = ?, session_expires_at = ? + WHERE request_token = ? AND status = 'pending' + `, sessionToken, sessionExpiresAt, requestToken) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errors.New("session not found or already processed") + } + return nil +} + +// DenyAgentSession denies a pending session +func (s *Store) DenyAgentSession(requestToken string) error { + result, err := s.db.Exec(` + UPDATE agent_sessions + SET status = 'denied' + WHERE request_token = ? AND status = 'pending' + `, requestToken) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errors.New("session not found or already processed") + } + return nil +} + +// GetPendingAgentSessions retrieves all unexpired pending sessions +func (s *Store) GetPendingAgentSessions() ([]models.AgentSession, error) { + rows, err := s.db.Query(` + SELECT id, request_token, agent_name, agent_id, status, created_at, expires_at + FROM agent_sessions + WHERE status = 'pending' AND expires_at > datetime('now', 'localtime') + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var sessions []models.AgentSession + for rows.Next() { + var session models.AgentSession + if err := rows.Scan( + &session.ID, + &session.RequestToken, + &session.AgentName, + &session.AgentID, + &session.Status, + &session.CreatedAt, + &session.ExpiresAt, + ); err != nil { + return nil, err + } + sessions = append(sessions, session) + } + return sessions, rows.Err() +} + +// InvalidatePreviousAgentSessions marks previous sessions for an agent as expired +func (s *Store) InvalidatePreviousAgentSessions(agentID string) error { + _, err := s.db.Exec(` + UPDATE agent_sessions + SET status = 'expired' + WHERE agent_id = ? AND status IN ('pending', 'approved') + `, agentID) + return err +} + +// GetAgentByAgentID retrieves an agent by their agent_id (UUID) +func (s *Store) GetAgentByAgentID(agentID string) (*models.Agent, error) { + var agent models.Agent + var lastSeen sql.NullTime + + err := s.db.QueryRow(` + SELECT id, name, agent_id, created_at, last_seen, trusted + FROM agents + WHERE agent_id = ? + `, agentID).Scan( + &agent.ID, + &agent.Name, + &agent.AgentID, + &agent.CreatedAt, + &lastSeen, + &agent.Trusted, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + if lastSeen.Valid { + agent.LastSeen = &lastSeen.Time + } + return &agent, nil +} + +// GetAgentByName retrieves an agent by name +func (s *Store) GetAgentByName(name string) (*models.Agent, error) { + var agent models.Agent + var lastSeen sql.NullTime + + err := s.db.QueryRow(` + SELECT id, name, agent_id, created_at, last_seen, trusted + FROM agents + WHERE name = ? + `, name).Scan( + &agent.ID, + &agent.Name, + &agent.AgentID, + &agent.CreatedAt, + &lastSeen, + &agent.Trusted, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + if lastSeen.Valid { + agent.LastSeen = &lastSeen.Time + } + return &agent, nil +} + +// CreateOrUpdateAgent creates or updates an agent record +func (s *Store) CreateOrUpdateAgent(name, agentID string) error { + _, err := s.db.Exec(` + INSERT INTO agents (name, agent_id, last_seen, trusted) + VALUES (?, ?, datetime('now'), 1) + ON CONFLICT(agent_id) DO UPDATE SET + name = excluded.name, + last_seen = datetime('now') + `, name, agentID) + return err +} + +// UpdateAgentLastSeen updates the last_seen timestamp for an agent +func (s *Store) UpdateAgentLastSeen(agentID string) error { + _, err := s.db.Exec(` + UPDATE agents SET last_seen = datetime('now') + WHERE agent_id = ? + `, agentID) + return err +} + +// GetAllAgents retrieves all agents +func (s *Store) GetAllAgents() ([]models.Agent, error) { + rows, err := s.db.Query(` + SELECT id, name, agent_id, created_at, last_seen, trusted + FROM agents + ORDER BY last_seen DESC NULLS LAST + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var agents []models.Agent + for rows.Next() { + var agent models.Agent + var lastSeen sql.NullTime + if err := rows.Scan( + &agent.ID, + &agent.Name, + &agent.AgentID, + &agent.CreatedAt, + &lastSeen, + &agent.Trusted, + ); err != nil { + return nil, err + } + if lastSeen.Valid { + agent.LastSeen = &lastSeen.Time + } + agents = append(agents, agent) + } + return agents, rows.Err() +} + +// RevokeAgent sets trusted=false for an agent +func (s *Store) RevokeAgent(agentID string) error { + _, err := s.db.Exec(`UPDATE agents SET trusted = 0 WHERE agent_id = ?`, agentID) + return err +} + +// CheckAgentTrust determines trust level for an agent request +func (s *Store) CheckAgentTrust(name, agentID string) (models.AgentTrustLevel, error) { + // Check if this exact agent_id is known + existingByID, err := s.GetAgentByAgentID(agentID) + if err != nil { + return "", err + } + + // Check if this name is known with a different ID + existingByName, err := s.GetAgentByName(name) + if err != nil { + return "", err + } + + if existingByID != nil && existingByID.Name == name && existingByID.Trusted { + return models.AgentTrustRecognized, nil + } + + if existingByName != nil && existingByName.AgentID != agentID { + return models.AgentTrustSuspicious, nil + } + + return models.AgentTrustNew, nil +} diff --git a/migrations/010_agent_tables.sql b/migrations/010_agent_tables.sql new file mode 100644 index 0000000..23e1c2c --- /dev/null +++ b/migrations/010_agent_tables.sql @@ -0,0 +1,30 @@ +-- Agent Context API tables +-- Migration: 010_agent_tables.sql + +-- Registered/approved agents (identity binding) +CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + agent_id TEXT NOT NULL UNIQUE, -- UUID from agent + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME, + trusted BOOLEAN DEFAULT 1 -- can be revoked +); + +-- Pending access requests and active sessions +CREATE TABLE IF NOT EXISTS agent_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_token TEXT NOT NULL UNIQUE, + agent_name TEXT NOT NULL, + agent_id TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- pending, approved, denied, expired + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, -- request expires after 5 min + session_token TEXT, -- populated on approval + session_expires_at DATETIME -- session TTL (1 hour) +); + +CREATE INDEX IF NOT EXISTS idx_agent_sessions_request ON agent_sessions(request_token); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_session ON agent_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_status ON agent_sessions(status); +CREATE INDEX IF NOT EXISTS idx_agents_agent_id ON agents(agent_id); diff --git a/web/static/js/app.js b/web/static/js/app.js index f103ae8..380bb70 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -265,3 +265,178 @@ function toggleTask(taskId) { }, { passive: true }); }); })(); + +// Agent Access Request Notifications +(function() { + let wsConnection = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 5; + const RECONNECT_DELAY_BASE = 1000; + + function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/notifications`; + + try { + wsConnection = new WebSocket(wsUrl); + + wsConnection.onopen = function() { + console.log('WebSocket connected'); + reconnectAttempts = 0; + }; + + wsConnection.onmessage = function(event) { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'agent_request') { + showAgentApprovalModal(msg.payload); + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e); + } + }; + + wsConnection.onclose = function(event) { + console.log('WebSocket disconnected'); + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts); + reconnectAttempts++; + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + setTimeout(connectWebSocket, delay); + } + }; + + wsConnection.onerror = function(error) { + console.error('WebSocket error:', error); + }; + } catch (e) { + console.error('Failed to create WebSocket:', e); + } + } + + function showAgentApprovalModal(payload) { + // Remove any existing modal + const existingModal = document.getElementById('agent-approval-modal'); + if (existingModal) { + existingModal.remove(); + } + + // Truncate agent ID for display + const shortAgentId = payload.agent_id.substring(0, 8); + + // Determine trust indicator + let trustBadge = ''; + let trustClass = ''; + switch (payload.trust_level) { + case 'recognized': + trustBadge = 'Recognized'; + trustClass = 'bg-green-500'; + break; + case 'suspicious': + trustBadge = 'Warning: Different ID'; + trustClass = 'bg-yellow-500'; + break; + default: + trustBadge = 'New Agent'; + trustClass = 'bg-blue-500'; + } + + // Calculate time remaining + const expiresAt = new Date(payload.expires_at); + const timeRemaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000)); + + const modal = document.createElement('div'); + modal.id = 'agent-approval-modal'; + modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; + modal.innerHTML = ` +
+
+

Agent Access Request

+ ${trustBadge} +
+
+

Agent Name: ${escapeHtml(payload.agent_name)}

+

Agent ID: ${shortAgentId}...

+

Expires in ${timeRemaining}s

+
+
+ + +
+
+ `; + + document.body.appendChild(modal); + + // Countdown timer + const countdownEl = document.getElementById('agent-countdown'); + const countdownInterval = setInterval(() => { + const remaining = Math.max(0, Math.floor((expiresAt - new Date()) / 1000)); + countdownEl.textContent = remaining; + if (remaining <= 0) { + clearInterval(countdownInterval); + modal.remove(); + } + }, 1000); + + // Button handlers + document.getElementById('agent-approve-btn').addEventListener('click', async () => { + await handleAgentDecision(payload.request_token, 'approve'); + clearInterval(countdownInterval); + modal.remove(); + }); + + document.getElementById('agent-deny-btn').addEventListener('click', async () => { + await handleAgentDecision(payload.request_token, 'deny'); + clearInterval(countdownInterval); + modal.remove(); + }); + + // Click outside to dismiss (treat as no action) + modal.addEventListener('click', (e) => { + if (e.target === modal) { + clearInterval(countdownInterval); + modal.remove(); + } + }); + } + + async function handleAgentDecision(requestToken, decision) { + try { + const response = await fetch(`/agent/auth/${decision}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCSRFToken() + }, + body: JSON.stringify({ request_token: requestToken }) + }); + + if (!response.ok) { + const error = await response.text(); + console.error(`Failed to ${decision} agent:`, error); + alert(`Failed to ${decision} agent request. Please try again.`); + } else { + console.log(`Agent request ${decision}d successfully`); + } + } catch (e) { + console.error(`Error during agent ${decision}:`, e); + alert(`Error processing request. Please try again.`); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Initialize WebSocket on page load + document.addEventListener('DOMContentLoaded', function() { + connectWebSocket(); + }); +})(); diff --git a/web/templates/agent-context.html b/web/templates/agent-context.html new file mode 100644 index 0000000..3a4778a --- /dev/null +++ b/web/templates/agent-context.html @@ -0,0 +1,121 @@ + + + + + + Agent Context - {{.AgentName}} + + + + + +
+

Agent Context

+

Timeline data for {{.AgentName}}

+ +
Generated At
+
{{.GeneratedAt}}
+ +
Date Range
+
{{.RangeStart}} to {{.RangeEnd}}
+
+ +
+

Summary

+
+
+
{{with .Summary}}{{.total_items}}{{else}}0{{end}}
+
Total Items
+
+
+
{{with .Summary}}{{.overdue}}{{else}}0{{end}}
+
Overdue
+
+
+
{{with .Summary}}{{.today}}{{else}}0{{end}}
+
Due Today
+
+
+
+ +
+

Timeline

+ {{if .Timeline}} + + + + + + + + + + + {{range .Timeline}} + + + + + + + {{end}} + +
SourceTitleDueType
{{.Source}} + {{if .URL}}{{.Title}}{{else}}{{.Title}}{{end}} + {{if .Description}}
{{.Description}}{{end}} +
{{if .Due}}{{.Due.Format "Jan 2, 3:04 PM"}}{{else}}-{{end}}{{.Type}}
+ {{else}} +

No items in the timeline for this date range.

+ {{end}} +
+ + diff --git a/web/templates/agent-error.html b/web/templates/agent-error.html new file mode 100644 index 0000000..afb3603 --- /dev/null +++ b/web/templates/agent-error.html @@ -0,0 +1,28 @@ + + + + + + Error - Agent API + + + + + +
+
{{.Status}}
+

Error

+

{{.Error}}

+
+ + diff --git a/web/templates/agent-request.html b/web/templates/agent-request.html new file mode 100644 index 0000000..fee5ca4 --- /dev/null +++ b/web/templates/agent-request.html @@ -0,0 +1,61 @@ + + + + + + Agent Auth Request - {{.AgentName}} + + + + + +
+

Agent Authentication Request

+

Agent {{.AgentName}} is requesting access to your dashboard.

+ +
Status
+
{{.Status}}
+
+ +
Request Token
+
{{.RequestToken}}
+ +
Poll URL
+
{{.PollURL}}
+ +
Expires At
+
{{.ExpiresAt}}
+
+ +
+ Next Steps: +
    +
  1. Wait for human approval on the dashboard
  2. +
  3. Poll the status URL: GET {{.PollURL}}
  4. +
  5. When status is "approved", extract the session token from the response
  6. +
  7. Use the context URL to fetch your timeline data
  8. +
+
+ + diff --git a/web/templates/agent-status.html b/web/templates/agent-status.html new file mode 100644 index 0000000..a77bb97 --- /dev/null +++ b/web/templates/agent-status.html @@ -0,0 +1,80 @@ + + + + + + Agent Status - {{.AgentName}} + + + + + +
+

Agent Status

+

Status for agent {{.AgentName}}

+ +
Status
+
{{.Status}}
+ + {{if eq .Status "approved"}} +
+ Access Granted! +

Your session has been approved. Use the credentials below to access the context.

+
+ +
+
Session Token
+
{{.SessionToken}}
+ +
Context URL
+
{{.ContextURL}}
+ + {{if .SessionExpiresAt}} +
Session Expires At
+
{{.SessionExpiresAt}}
+ {{end}} + +
+ Next Step: +

Fetch your timeline data by navigating to: GET {{.ContextURL}}

+
+ {{else if eq .Status "pending"}} +
+ Waiting for approval... +

Refresh this page to check the current status.

+
+ {{else if eq .Status "denied"}} +
+ Access Denied +

Your request was denied by the dashboard owner.

+
+ {{else if eq .Status "expired"}} +
+ Request Expired +

Your authentication request has expired. Please initiate a new request.

+
+ {{end}} +
+ + -- cgit v1.2.3