diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 22:19:28 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-28 22:19:28 -1000 |
| commit | 05b1930e04ac222d73ffb2f45c1b1febb69f893d (patch) | |
| tree | bc451d72b5265ff044c4655ed90685c601688b6d /web/static | |
| parent | 058ff7d699f088edb851336928dd3eea2934cc07 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'web/static')
| -rw-r--r-- | web/static/js/app.js | 175 |
1 files changed, 175 insertions, 0 deletions
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 = ` + <div class="bg-gray-900 rounded-lg p-6 max-w-md mx-4 shadow-xl border border-gray-700"> + <div class="flex items-center justify-between mb-4"> + <h2 class="text-xl font-semibold text-white">Agent Access Request</h2> + <span class="px-2 py-1 text-xs rounded ${trustClass} text-white">${trustBadge}</span> + </div> + <div class="mb-6 text-gray-300"> + <p class="mb-2"><strong class="text-white">Agent Name:</strong> ${escapeHtml(payload.agent_name)}</p> + <p class="mb-2"><strong class="text-white">Agent ID:</strong> <code class="bg-gray-800 px-1 rounded">${shortAgentId}...</code></p> + <p class="text-sm text-gray-400">Expires in <span id="agent-countdown">${timeRemaining}</span>s</p> + </div> + <div class="flex gap-3"> + <button id="agent-approve-btn" class="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded transition"> + Approve + </button> + <button id="agent-deny-btn" class="flex-1 bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition"> + Deny + </button> + </div> + </div> + `; + + 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(); + }); +})(); |
