// Pod/agent management routes — extracted from registry.js (GH#112) // Handles: list agents in pod, get single agent, uninstall agent const express = require('express'); const auth = require('../../middleware/auth'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const AgentProfile = require('../../models/AgentProfile'); const AgentTemplate = require('../../models/AgentTemplate'); const AgentEvent = require('../../models/AgentEvent'); const AgentCredential = require('../../models/AgentCredential'); const Machine = require('../../models/Machine'); const { deriveAgentConnection } = require('../../services/agentConnectionStateService'); const { hostedRunsNative } = require('../../services/hostedEngine'); const Pod = require('../../models/Pod'); const User = require('../../models/User'); const AgentIdentityService = require('../../services/agentIdentityService'); const { revokeRuntimeTokensAfterLastUninstall } = require('./tokens'); const { getUserId, isGlobalAdminUser, normalizeInstanceId, normalizeConfigMap, normalizeDisplayName, resolveInstallation, buildAgentProfileId, buildAgentInstallationPayload, resolveSeatRuntimeIdentities, } = require('./helpers'); const podAgentsRouter = express.Router(); /** * DELETE /api/registry/agents/:name/pods/:podId * Uninstall an agent from a pod */ podAgentsRouter.delete('/agents/:name/pods/:podId', auth, async (req: any, res: any) => { try { const { name, podId } = req.params; // Destructive writes must never use resolveInstallation's single-install // fallback: a stale instanceId could otherwise remove a different agent. const instanceId = normalizeInstanceId(req.query.instanceId); const installation = await AgentInstallation.findOne({ agentName: String(name).toLowerCase(), podId, instanceId, status: { $ne: 'uninstalled' }, }); const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } const isGlobalAdmin = await isGlobalAdminUser(userId); const pod = await Pod.findById(podId).lean(); if (!pod) { return res.status(404).json({ error: 'Pod not found' }); } const isCreator = pod.createdBy?.toString() === userId.toString(); const membership = pod.members?.find((m: any) => { if (!m) return false; const memberId = m.userId?.toString?.() || m.toString?.(); return memberId && memberId === userId.toString(); }); if (!membership && !isCreator && !isGlobalAdmin) { return res.status(403).json({ error: 'Access denied' }); } if (!installation) { if (!isCreator && !isGlobalAdmin) { return res.status(404).json({ error: 'Agent not installed in this pod' }); } const resolvedType = AgentIdentityService.resolveAgentType(name); const username = AgentIdentityService.buildAgentUsername(resolvedType, instanceId); const candidateUser = await User.findOne({ username, isBot: true }) .select('_id agentRuntimeTokens botMetadata').lean(); const agentUser = candidateUser?.botMetadata?.agentName === resolvedType && candidateUser?.botMetadata?.instanceId === instanceId ? candidateUser : null; const hasProfile = await AgentProfile.exists({ agentId: buildAgentProfileId(name, instanceId), podId }); const hasMembership = agentUser && pod.members?.some((member: any) => ( String(member?.userId || member) === String(agentUser._id) )); const hasCredential = agentUser && await AgentCredential.exists({ agentUserId: agentUser._id, kind: 'runtime', status: 'active', }); const installationNames = Array.from(new Set([name.toLowerCase(), resolvedType])); const activeIdentity = { agentName: { $in: installationNames }, instanceId, status: 'active' }; const hasActiveInstallationHere = await AgentInstallation.exists({ ...activeIdentity, podId, }).collation({ locale: 'en', strength: 2 }); if (hasActiveInstallationHere) { return res.status(404).json({ error: `Agent not installed under ${name}. Use agentName ${resolvedType} in the URL and instanceId as a query parameter.`, }); } const hasActiveInstallation = await AgentInstallation.exists(activeIdentity) .collation({ locale: 'en', strength: 2 }); const hasOrphanCredential = !hasActiveInstallation && (agentUser?.agentRuntimeTokens?.length || hasCredential); const hasPodArtifact = hasProfile || hasMembership; if (!hasPodArtifact && (!isGlobalAdmin || !hasOrphanCredential)) { return res.status(404).json({ error: 'Agent not installed in this pod. Pass its agentName in the URL and instanceId as a query parameter.', }); } await AgentProfile.deleteOne({ agentId: buildAgentProfileId(name, instanceId), podId }); if (agentUser) { // Older uninstall paths could leave a bearer on an orphaned User row. await revokeRuntimeTokensAfterLastUninstall(name, instanceId); try { await AgentIdentityService.removeAgentFromPod(username, podId); } catch (identityError: unknown) { console.warn('Failed to remove agent user from pod:', (identityError as Error).message); } } return res.json({ success: true, removedOrphan: true }); } const isInstaller = installation.installedBy?.toString?.() === userId.toString(); if (!isCreator && !isInstaller && !isGlobalAdmin) { return res.status(403).json({ error: 'Only pod admins or installers can remove agents' }); } await AgentInstallation.uninstall(name, podId, instanceId); await revokeRuntimeTokensAfterLastUninstall(name, instanceId); await AgentProfile.deleteOne({ agentId: buildAgentProfileId(name, instanceId), podId }); // ADR-006 OQ #1: ephemeral self-serve registry rows whose only // installation was just removed are leaked here. v1 punts; the GC // janitor lands when orphan-row volume warrants it. try { const resolvedType = AgentIdentityService.resolveAgentType(name); await AgentIdentityService.removeAgentFromPod( AgentIdentityService.buildAgentUsername(resolvedType, instanceId), podId, ); } catch (identityError: unknown) { console.warn('Failed to remove agent user from pod:', (identityError as Error).message); } res.json({ success: true }); } catch (error) { console.error('Error uninstalling agent:', error); res.status(500).json({ error: 'Failed to uninstall agent' }); } }); /** * GET /api/registry/pods/:podId/agents * List agents installed in a pod */ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { try { const { podId } = req.params; const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } const pod = await Pod.findById(podId).lean(); if (!pod) { return res.status(404).json({ error: 'Pod not found' }); } const isCreator = pod.createdBy?.toString() === userId.toString(); // §3.7 read-access: members + creator allowed; for agent-dm, anyone // sharing a pod with either bot member is also allowed. // eslint-disable-next-line global-require const DMService = require('../../services/dmService'); const canView = await DMService.canViewPod(userId, pod); if (!canView && !isCreator) { return res.status(403).json({ error: 'Access denied' }); } const installations = await AgentInstallation.getInstalledAgents(podId); // One derivation for proof-of-life (heartbeats ∪ runs ∪ token use) — // shared with the native runtime's commonly_agent_status tool via // agentStateService. Advisory: never fail the roster over it. let activityMap = new Map(); try { const { collectPodAgentActivity } = require('../../services/agentStateService'); activityMap = await collectPodAgentActivity(String(podId), installations); } catch (activityErr) { console.warn('[pod-agents] activity lookup failed:', (activityErr as Error).message); } // Heartbeat-only timestamps still ride the payload (lastHeartbeatAt is // its own field); fetch them cheaply from the same event source. const heartbeatRows = await AgentEvent.aggregate([ { $match: { type: 'heartbeat', status: 'delivered', agentName: { $in: installations.map((i: any) => i.agentName) }, instanceId: { $in: installations.map((i: any) => i.instanceId || 'default') }, }, }, { $sort: { createdAt: -1 } }, { $group: { _id: { agentName: '$agentName', instanceId: '$instanceId' }, lastHeartbeatAt: { $first: '$createdAt' }, }, }, ]); const heartbeatMap = new Map( heartbeatRows.map((r: any) => [`${r._id.agentName}:${r._id.instanceId}`, r.lastHeartbeatAt]), ); const registryEntries = await AgentRegistry.find({ agentName: { $in: installations.map((i: any) => i.agentName) }, }).select('agentName iconUrl').lean(); const iconMap = new Map(registryEntries.map((entry: any) => [entry.agentName, entry.iconUrl || ''])); const installationDisplayNames = Array.from(new Set( installations.map((i: any) => i.displayName).filter(Boolean), )); const templateCandidates = await AgentTemplate.find({ agentName: { $in: installations.map((i: any) => i.agentName) }, displayName: { $in: installationDisplayNames }, $or: [ { visibility: 'public' }, { createdBy: userId }, { createdBy: { $in: installations.map((i: any) => i.installedBy).filter(Boolean) } }, ], }).select('agentName displayName iconUrl createdBy visibility').lean(); const getTemplateIcon = (installation: any) => { const displayName = normalizeDisplayName(installation.displayName); if (!displayName) return ''; const matches = templateCandidates.filter((template: any) => ( template.agentName === installation.agentName && normalizeDisplayName(template.displayName) === displayName && template.iconUrl )); if (matches.length === 0) return ''; const installedBy = installation.installedBy?.toString?.() || String(installation.installedBy || ''); const exactOwner = matches.find((template: any) => String(template.createdBy || '') === installedBy); if (exactOwner) return exactOwner.iconUrl; const currentUserTemplate = matches.find((template: any) => String(template.createdBy || '') === String(userId)); if (currentUserTemplate) return currentUserTemplate.iconUrl; const publicTemplate = matches.find((template: any) => template.visibility === 'public'); return (publicTemplate || matches[0]).iconUrl || ''; }; const profiles = await AgentProfile.find({ podId, agentName: { $in: installations.map((i: any) => i.agentName) }, }).lean(); // Batch-fetch the bot User rows so the payload builder can resolve // displayName via the curated `botMetadata.displayName` rather than // the (possibly stale) `installation.displayName`. ADR-001 §3.10: // identity is separate from package — the User row is the source of // truth for identity labels. Map by (agentName, instanceId). const User = require('../../models/User'); const usernameSet = new Set(); for (const i of installations as any[]) { const username = AgentIdentityService.buildAgentUsername(i.agentName, i.instanceId || 'default'); if (username) usernameSet.add(username); } const userRows = usernameSet.size > 0 ? await User.find({ username: { $in: Array.from(usernameSet) } }) .select('username profilePicture botMetadata agentRuntimeTokens.tokenHash agentRuntimeTokens.lastUsedAt agentRuntimeTokens.expiresAt') .lean() : []; const userByUsername = new Map( (userRows as any[]).map((u: any) => [u.username, u]), ); // Connection health is advisory, like activity. Read credentials in one // batch; a revoked row survives removal of the embedded bearer. let credentialByAgent = new Map(); let credentialByMachine = new Map(); let machineById = new Map(); let connectionAvailable = true; try { if ((userRows as any[]).length > 0) { const agentIds = (userRows as any[]).map((u: any) => u._id); const machineIds = Array.from(new Set((userRows as any[]) .map((u: any) => u.botMetadata?.machineId).filter(Boolean))); const [credentials, machines] = await Promise.all([ AgentCredential.find({ $or: [ { kind: 'runtime', agentUserId: { $in: agentIds } }, { kind: 'daemon', machineId: { $in: machineIds } }, ] }).select('kind agentUserId machineId tokenHash status lastUsedAt expiresAt').lean(), Machine.find({ machineId: { $in: machineIds } }).select('machineId lastSeenAt').lean(), ]); for (const credential of credentials as any[]) { const key = credential.kind === 'daemon' ? String(credential.machineId) : String(credential.agentUserId); const target = credential.kind === 'daemon' ? credentialByMachine : credentialByAgent; target.set(key, [...(target.get(key) || []), credential]); } machineById = new Map((machines as any[]).map((machine: any) => [machine.machineId, machine])); } } catch (connectionErr) { connectionAvailable = false; console.warn('[pod-agents] connection lookup failed:', (connectionErr as Error).message); } // Last thing each agent said in THIS pod (Wren spec §1.1 line 2). // Advisory like the activity map: a PG hiccup must never fail the roster. let lastMessageByUserId = new Map(); try { // eslint-disable-next-line global-require const PgMessage = require('../../models/pg/Message'); const rows = await PgMessage.findLastMessagePerUserInPod( String(podId), (userRows as any[]).map((u: any) => u._id), ); lastMessageByUserId = new Map( (rows as Array<{ userId: string; content: string; createdAt: unknown }>) .map((r) => [r.userId, { content: r.content, createdAt: r.createdAt }]), ); } catch (snippetErr) { console.warn('[pod-agents] last-message lookup failed:', (snippetErr as Error).message); } // Day-zero step 2 (#1648, ux-lead 67071 / sprint-impl 67078): "Say something to it" closes // on the caller's own message in this pod, reply or not. A seat's install intro, a heartbeat // or a teammate's post is not the caller speaking, so no per-agent field can carry this. // Advisory like the snippet: a PG hiccup reads as "not yet", never as a failed roster. let callerSpoke = false; try { // eslint-disable-next-line global-require const PgMessage = require('../../models/pg/Message'); callerSpoke = Boolean(await PgMessage.hasMessageByUserInPod(String(podId), String(userId))); } catch (spokeErr) { console.warn('[pod-agents] caller-spoke lookup failed:', (spokeErr as Error).message); } // A seat's runtime identity comes from its newest install in any pod // (TASK-569). Advisory: on a failed read each install shows its own. let seatRuntimeIdentities = new Map>(); try { seatRuntimeIdentities = await resolveSeatRuntimeIdentities(installations); } catch (identityErr) { console.warn('[pod-agents] runtime identity lookup failed:', (identityErr as Error).message); } // A push webhook's URL can carry a credential in its query string, so only // its installer and instance admins see it (Cofounder's hardening ruling, // 2026-10-21). The admin lookup runs only when the roster holds a URL the // viewer did not install. const viewerId = String(userId); const installedByViewer = (i: any) => String(i.installedBy || '') === viewerId; const needsAdminCheck = (installations as any[]).some((i: any) => ( !installedByViewer(i) && Boolean(normalizeConfigMap(i.config)?.runtime?.webhookUrl) )); const viewerIsAdmin = needsAdminCheck ? await isGlobalAdminUser(userId) : false; res.json({ callerSpoke, agents: installations.map((i: any) => { const profile = profiles.find( (p: any) => p.agentName === i.agentName && p.instanceId === (i.instanceId || 'default'), ); const templateIcon = getTemplateIcon(i); const instanceKey = `${i.agentName}:${i.instanceId || 'default'}`; const username = AgentIdentityService.buildAgentUsername(i.agentName, i.instanceId || 'default'); const user = userByUsername.get(username) || null; const lastActiveAt = activityMap.get(instanceKey) || null; const payload = buildAgentInstallationPayload(i, { profile, iconUrl: templateIcon || iconMap.get(i.agentName) || '', lastHeartbeatAt: heartbeatMap.get(instanceKey) || null, lastActiveAt, user, lastMessage: (user && lastMessageByUserId.get(String(user._id))) || null, revealWebhookUrl: viewerIsAdmin || installedByViewer(i), runtimeIdentity: seatRuntimeIdentities.get(instanceKey) || null, }); const runtimeType = payload?.runtime?.runtimeType; // A provisioned in-process seat needs no external connection. Its // runtime is available by construction, without a credential ledger. if (runtimeType === 'native' || hostedRunsNative(i)) { return { ...payload, connection: { state: 'available', lastSeenAt: null } }; } // Legacy local-cli installs are normalized to their harness name // (codex/claude-code) in the public payload. Host, not that label, // identifies a BYO listener whose credential use proves liveness. // A seat bound to a daemon machine is a wrapper whatever its install // label says: manage-settings projects it as `wrapper` for the same // reason. Without this, a running daemon-managed seat carried no // connection and Settings rendered "Connection status unavailable". // The runtime type is projected as `wrapper` too: Settings keys its // machine-key action and Reconnect link on that field, not on connection. const machineId = user?.botMetadata?.machineId; const daemonBound = Boolean(machineId); // The harness label moves to `adapter`, so the chat tag keeps naming it. const seatPayload = daemonBound ? { ...payload, runtime: { ...payload?.runtime, runtimeType: 'wrapper', adapter: payload?.runtime?.adapter || payload?.runtime?.runtimeType, } } : payload; const hasRuntimeConnection = payload?.runtime?.host === 'byo' || runtimeType === 'wrapper' || daemonBound || (runtimeType === 'webhook' && !payload?.runtime?.host); if (!connectionAvailable || !user || !hasRuntimeConnection || normalizeConfigMap(i.config)?.runtime?.webhookUrl) return seatPayload; return { ...seatPayload, connection: deriveAgentConnection({ runtimeType: daemonBound ? 'wrapper' : runtimeType, credentials: credentialByAgent.get(String(user._id)) || [], machineCredentials: credentialByMachine.get(String(machineId)) || [], embeddedTokens: [...(user.agentRuntimeTokens || []), ...(i.runtimeTokens || [])], machineLastSeenAt: machineById.get(String(machineId))?.lastSeenAt || null, }), }; }), }); } catch (error) { console.error('Error listing pod agents:', error); res.status(500).json({ error: 'Failed to list pod agents' }); } }); /** * GET /api/registry/pods/:podId/agents/:name?instanceId= * Return a single installed agent payload with latest persisted config/profile. */ podAgentsRouter.get('/pods/:podId/agents/:name', auth, async (req: any, res: any) => { try { const { podId, name } = req.params; const { installation } = await resolveInstallation({ agentName: name, podId, instanceId: req.query.instanceId, }); const userId = getUserId(req); if (!userId) { return res.status(401).json({ error: 'Unauthorized' }); } const pod = await Pod.findById(podId).lean(); if (!pod) { return res.status(404).json({ error: 'Pod not found' }); } const isCreator = pod.createdBy?.toString() === userId.toString(); const membership = pod.members?.find((m: any) => { if (!m) return false; const memberId = m.userId?.toString?.() || m.toString?.(); return memberId && memberId === userId.toString(); }); if (!membership && !isCreator && !(await isGlobalAdminUser(userId))) { return res.status(403).json({ error: 'Access denied' }); } if (!installation || installation.status === 'uninstalled') { return res.status(404).json({ error: 'Agent not installed in this pod' }); } const [registryEntry, profile, templateCandidates] = await Promise.all([ AgentRegistry.findOne({ agentName: installation.agentName }).select('iconUrl').lean(), AgentProfile.findOne({ podId, agentName: installation.agentName, instanceId: installation.instanceId || 'default', }).lean(), AgentTemplate.find({ agentName: installation.agentName, displayName: installation.displayName, $or: [ { visibility: 'public' }, { createdBy: userId }, { createdBy: installation.installedBy }, ], }).select('iconUrl createdBy visibility').lean(), ]); const installedBy = installation.installedBy?.toString?.() || String(installation.installedBy || ''); const templateIcon = ( templateCandidates.find((template: any) => String(template.createdBy || '') === installedBy) || templateCandidates.find((template: any) => String(template.createdBy || '') === String(userId)) || templateCandidates.find((template: any) => template.visibility === 'public') || templateCandidates[0] )?.iconUrl || ''; let runtimeIdentity = null; try { const identities = await resolveSeatRuntimeIdentities([installation]); runtimeIdentity = identities.values().next().value || null; } catch (identityErr) { console.warn('[pod-agents] runtime identity lookup failed:', (identityErr as Error).message); } return res.json({ agent: buildAgentInstallationPayload(installation, { profile, runtimeIdentity, iconUrl: templateIcon || registryEntry?.iconUrl || '', revealWebhookUrl: installedBy === String(userId) || (Boolean(normalizeConfigMap(installation.config)?.runtime?.webhookUrl) && await isGlobalAdminUser(userId)), }), }); } catch (error) { console.error('Error loading installed pod agent:', error); return res.status(500).json({ error: 'Failed to load installed agent' }); } }); module.exports = podAgentsRouter; export {};