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:
co-authored by
Claude Opus 4.5
parent
bf0ec80fb5
commit
6a9b57ae0c
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user