Simplify agent list UI and add bearer token auth for Codex MCP

- Replace SectionList with FlatList in agent list, sorting attention items to top
- Persist server selection preference in create agent modal
- Add bearer token authentication support for agent-control MCP endpoint
- Pass bearer token through Codex wrapper script for MCP auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-23 22:11:51 +07:00
co-authored by Claude Opus 4.5
parent bf0ec80fb5
commit 6a9b57ae0c
4 changed files with 109 additions and 114 deletions
+39 -106
View File
@@ -1,10 +1,8 @@
import { View, Text, Pressable, SectionList, Modal, RefreshControl, type SectionListRenderItem } from "react-native"; import { View, Text, Pressable, FlatList, Modal, RefreshControl, type ListRenderItem } from "react-native";
import { useCallback, useState, useMemo } from "react"; import { useCallback, useState, useMemo } from "react";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -16,37 +14,18 @@ interface AgentListProps {
onRefresh?: () => void; onRefresh?: () => void;
} }
interface AgentSection {
title: string;
data: AggregatedAgent[];
}
export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) { export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
// Group agents by attention status // Sort agents with requires attention at the top
const sections = useMemo<AgentSection[]>(() => { const sortedAgents = useMemo(() => {
const requiresAttention: AggregatedAgent[] = []; return [...agents].sort((a, b) => {
const normal: AggregatedAgent[] = []; // Requires attention first
if (a.requiresAttention && !b.requiresAttention) return -1;
for (const agent of agents) { if (!a.requiresAttention && b.requiresAttention) return 1;
if (agent.requiresAttention) { return 0;
requiresAttention.push(agent); });
} else {
normal.push(agent);
}
}
const result: AgentSection[] = [];
if (requiresAttention.length > 0) {
result.push({ title: "Requires Attention", data: requiresAttention });
}
if (normal.length > 0) {
result.push({ title: "All Agents", data: normal });
}
return result;
}, [agents]); }, [agents]);
// Get the methods for the specific server // Get the methods for the specific server
@@ -103,30 +82,11 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList
setActionAgent(null); setActionAgent(null);
}, [actionAgent, deleteAgent]); }, [actionAgent, deleteAgent]);
const handleClearAllAttention = useCallback((agentsInSection: AggregatedAgent[]) => { const renderAgentItem = useCallback<ListRenderItem<AggregatedAgent>>(
// Group agents by serverId for batch clearing
const agentsByServer = new Map<string, string[]>();
for (const agent of agentsInSection) {
const agentIds = agentsByServer.get(agent.serverId) || [];
agentIds.push(agent.id);
agentsByServer.set(agent.serverId, agentIds);
}
// Send one batch request per server
for (const [serverId, agentIds] of agentsByServer) {
const session = useSessionStore.getState().sessions[serverId];
if (session?.ws) {
session.ws.clearAgentAttention(agentIds);
}
}
}, []);
const renderAgentItem = useCallback<SectionListRenderItem<AggregatedAgent, AgentSection>>(
({ item: agent }) => { ({ item: agent }) => {
const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status);
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const providerLabel = getAgentProviderDefinition(agent.provider).label; const providerLabel = getAgentProviderDefinition(agent.provider).label;
const isRunning = agent.status === "running";
return ( return (
<Pressable <Pressable
@@ -172,14 +132,22 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList
</Text> </Text>
</View> </View>
<View style={styles.statusBadge}> {agent.requiresAttention && (
<View <View style={[styles.attentionBadge, { backgroundColor: theme.colors.primary }]}>
style={[styles.statusDot, { backgroundColor: statusColor }]} <Text style={[styles.attentionText, { color: theme.colors.primaryForeground }]}>
/> Needs attention
<Text style={[styles.statusText, { color: statusColor }]}> </Text>
{statusLabel} </View>
</Text> )}
</View>
{isRunning && (
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: "#3b82f6" }]} />
<Text style={[styles.statusText, { color: "#3b82f6" }]}>
Running
</Text>
</View>
)}
</View> </View>
<Text style={styles.timeAgo}> <Text style={styles.timeAgo}>
@@ -190,7 +158,7 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList
</Pressable> </Pressable>
); );
}, },
[handleAgentLongPress, handleAgentPress, theme.colors.muted, theme.colors.mutedForeground] [handleAgentLongPress, handleAgentPress, theme.colors.muted, theme.colors.mutedForeground, theme.colors.primary, theme.colors.primaryForeground]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback(
@@ -198,38 +166,16 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList
[] []
); );
const renderSectionHeader = useCallback(
({ section }: { section: AgentSection }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
{section.title === "Requires Attention" && (
<Pressable
style={({ pressed }) => [
styles.clearAllButton,
pressed && styles.clearAllButtonPressed,
]}
onPress={() => handleClearAllAttention(section.data)}
>
<Check size={16} color={theme.colors.mutedForeground} />
</Pressable>
)}
</View>
),
[handleClearAllAttention, theme.colors.mutedForeground]
);
return ( return (
<> <>
<SectionList <FlatList
sections={sections} data={sortedAgents}
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderAgentItem} renderItem={renderAgentItem}
renderSectionHeader={renderSectionHeader}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
stickySectionHeadersEnabled={false}
refreshControl={ refreshControl={
onRefresh ? ( onRefresh ? (
<RefreshControl <RefreshControl
@@ -296,28 +242,6 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[4], paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
sectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.mutedForeground,
textTransform: "uppercase",
},
clearAllButton: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
clearAllButtonPressed: {
opacity: 0.5,
backgroundColor: theme.colors.muted,
},
agentItem: { agentItem: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
@@ -392,6 +316,15 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
attentionBadge: {
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
},
attentionText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
timeAgo: { timeAgo: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
@@ -470,6 +470,7 @@ function AgentFlowModal({
mode: false, mode: false,
model: false, model: false,
workingDir: false, workingDir: false,
serverId: false,
}); });
const prevVisibilityRef = useRef(isVisible); const prevVisibilityRef = useRef(isVisible);
const dictationRequestIdRef = useRef<string | null>(null); const dictationRequestIdRef = useRef<string | null>(null);
@@ -730,6 +731,7 @@ function AgentFlowModal({
provider?: AgentProvider; provider?: AgentProvider;
mode?: string; mode?: string;
model?: string; model?: string;
serverId?: string;
}; };
if ( if (
parsed.provider && parsed.provider &&
@@ -756,6 +758,12 @@ function AgentFlowModal({
) { ) {
setSelectedModel(parsed.model); setSelectedModel(parsed.model);
} }
if (
typeof parsed.serverId === "string" &&
!userEditedPreferencesRef.current.serverId
) {
setSelectedServerId(parsed.serverId);
}
} catch (error) { } catch (error) {
console.error( console.error(
"[CreateAgentModal] Failed to hydrate form preferences:", "[CreateAgentModal] Failed to hydrate form preferences:",
@@ -786,6 +794,7 @@ function AgentFlowModal({
provider: selectedProvider, provider: selectedProvider,
mode: selectedMode, mode: selectedMode,
model: selectedModel, model: selectedModel,
serverId: selectedServerId,
}) })
); );
} catch (error) { } catch (error) {
@@ -796,7 +805,7 @@ function AgentFlowModal({
} }
}; };
void persist(); void persist();
}, [selectedMode, selectedProvider, workingDir]); }, [selectedMode, selectedProvider, workingDir, selectedModel, selectedServerId]);
const providerFilterOptions = useMemo( const providerFilterOptions = useMemo(
() => [ () => [
@@ -1250,6 +1259,7 @@ function AgentFlowModal({
}, [router, selectedDaemonId]); }, [router, selectedDaemonId]);
const handleSelectServer = useCallback((serverId: string) => { const handleSelectServer = useCallback((serverId: string) => {
userEditedPreferencesRef.current.serverId = true;
setSelectedServerId(serverId); setSelectedServerId(serverId);
}, []); }, []);
@@ -277,9 +277,29 @@ function resolveAgentControlMcpUrl(config: CodexAgentConfig): string | null {
: baseUrl; : baseUrl;
} }
function resolveAgentControlBearerToken(
config: CodexAgentConfig
): string | null {
const header = config.agentControlMcp?.headers?.Authorization;
if (!header || typeof header !== "string") {
return null;
}
const trimmed = header.trim();
if (trimmed.toLowerCase().startsWith("bearer ")) {
const token = trimmed.slice("bearer ".length).trim();
return token.length > 0 ? token : null;
}
if (trimmed.toLowerCase().startsWith("basic ")) {
const token = trimmed.slice("basic ".length).trim();
return token.length > 0 ? token : null;
}
return null;
}
async function createCodexWrapperScript( async function createCodexWrapperScript(
realCodexPath: string, realCodexPath: string,
mcpUrl: string, mcpUrl: string,
mcpBearerToken: string | null,
developerInstructions: string developerInstructions: string
): Promise<CodexWrapperInfo> { ): Promise<CodexWrapperInfo> {
const templatePath = await resolveCodexWrapperTemplatePath(); const templatePath = await resolveCodexWrapperTemplatePath();
@@ -289,6 +309,10 @@ async function createCodexWrapperScript(
const content = template const content = template
.replace("__REAL_CODEX__", escapeBashValue(realCodexPath)) .replace("__REAL_CODEX__", escapeBashValue(realCodexPath))
.replace("__MCP_URL__", escapeBashValue(mcpUrl)) .replace("__MCP_URL__", escapeBashValue(mcpUrl))
.replace(
"__MCP_BEARER__",
escapeBashValue(mcpBearerToken ?? "")
)
.replace( .replace(
"__DEV_INSTRUCTIONS__", "__DEV_INSTRUCTIONS__",
escapeBashTomlValue(encodeTomlBasicString(developerInstructions)) escapeBashTomlValue(encodeTomlBasicString(developerInstructions))
@@ -459,6 +483,7 @@ export class CodexAgentClient implements AgentClient {
config: CodexAgentConfig config: CodexAgentConfig
): Promise<CodexSessionBootstrap> { ): Promise<CodexSessionBootstrap> {
const mcpUrl = resolveAgentControlMcpUrl(config); const mcpUrl = resolveAgentControlMcpUrl(config);
const mcpBearerToken = resolveAgentControlBearerToken(config);
const developerInstructions = resolveDeveloperInstructions(config); const developerInstructions = resolveDeveloperInstructions(config);
if ((!mcpUrl && !developerInstructions) || !this.baseCodexPath) { if ((!mcpUrl && !developerInstructions) || !this.baseCodexPath) {
if ((mcpUrl || developerInstructions) && !this.baseCodexPath) { if ((mcpUrl || developerInstructions) && !this.baseCodexPath) {
@@ -470,6 +495,7 @@ export class CodexAgentClient implements AgentClient {
const wrapper = await createCodexWrapperScript( const wrapper = await createCodexWrapperScript(
this.baseCodexPath, this.baseCodexPath,
mcpUrl ?? "", mcpUrl ?? "",
mcpBearerToken,
developerInstructions ?? "" developerInstructions ?? ""
); );
const env = { const env = {
+33 -7
View File
@@ -28,17 +28,34 @@ const BASIC_AUTH_USERS = { mo: "bo" } as const;
function createServer() { function createServer() {
const app = express(); const app = express();
const [agentMcpUser, agentMcpPassword] =
Object.entries(BASIC_AUTH_USERS)[0] ?? [];
const agentMcpBearerToken =
agentMcpUser && agentMcpPassword
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
// Serve static files from public directory (no auth required for APK downloads) // Serve static files from public directory (no auth required for APK downloads)
app.use("/public", express.static("public")); app.use("/public", express.static("public"));
// Basic authentication (skip for /public routes) // Basic authentication (skip for /public routes)
app.use( const basicAuthMiddleware = basicAuth({
basicAuth({ users: BASIC_AUTH_USERS,
users: BASIC_AUTH_USERS, challenge: true,
challenge: true, realm: "Voice Assistant",
realm: "Voice Assistant", });
}) app.use((req, res, next) => {
); if (agentMcpBearerToken && req.path.startsWith("/mcp/agents")) {
const authHeader = req.header("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) {
const token = authHeader.slice("Bearer ".length).trim();
if (token === agentMcpBearerToken) {
return next();
}
}
}
return basicAuthMiddleware(req, res, next);
});
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
@@ -155,6 +172,15 @@ async function main() {
req, req,
res res
) => { ) => {
if (process.env.MCP_DEBUG === "1") {
console.log("[Agent MCP] request", {
method: req.method,
url: req.originalUrl,
sessionId: req.header("mcp-session-id"),
authorization: req.header("authorization"),
body: req.body,
});
}
try { try {
const sessionId = req.header("mcp-session-id"); const sessionId = req.header("mcp-session-id");
let transport = sessionId let transport = sessionId