fix: embedding migration, worker error handling, path traversal security, response mismatches (unread-count, plugins, manifests), frontend type safety

This commit is contained in:
Agent Zero
2026-07-24 14:23:28 +02:00
parent c3c5233e58
commit 7b4a2c0791
10 changed files with 161 additions and 31 deletions
+4
View File
@@ -346,6 +346,10 @@ def create_app() -> FastAPI:
# Don't intercept API routes # Don't intercept API routes
if full_path.startswith(("api/", "docs", "openapi", "redoc")): if full_path.startswith(("api/", "docs", "openapi", "redoc")):
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
# Block path traversal and system file access
blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/")
if full_path.startswith(blocked_prefixes) or "/../" in full_path or full_path.endswith("/.."):
raise HTTPException(status_code=404, detail="Not Found")
index_path = os.path.join(frontend_dist, "index.html") index_path = os.path.join(frontend_dist, "index.html")
if os.path.isfile(index_path): if os.path.isfile(index_path):
return FileResponse(index_path) return FileResponse(index_path)
@@ -42,6 +42,10 @@ async def index_mails(ctx: dict[str, Any], mail_ids: list[str]) -> None:
await index_entity("mail", eid, tenant_id, db) await index_entity("mail", eid, tenant_id, db)
except Exception: except Exception:
logger.exception("Failed to index mail %s", mail_id) logger.exception("Failed to index mail %s", mail_id)
try:
await db.rollback()
except Exception:
pass
async def index_file(ctx: dict[str, Any], file_id: str) -> None: async def index_file(ctx: dict[str, Any], file_id: str) -> None:
@@ -98,6 +102,10 @@ async def index_file(ctx: dict[str, Any], file_id: str) -> None:
logger.info("Indexed file %s", file_id) logger.info("Indexed file %s", file_id)
except Exception: except Exception:
logger.exception("Failed to index file %s", file_id) logger.exception("Failed to index file %s", file_id)
try:
await db.rollback()
except Exception:
pass
async def index_contact(ctx: dict[str, Any], contact_id: str) -> None: async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
@@ -119,6 +127,10 @@ async def index_contact(ctx: dict[str, Any], contact_id: str) -> None:
await index_entity("contact", eid, row["tenant_id"], db) await index_entity("contact", eid, row["tenant_id"], db)
except Exception: except Exception:
logger.exception("Failed to index contact %s", contact_id) logger.exception("Failed to index contact %s", contact_id)
try:
await db.rollback()
except Exception:
pass
async def index_event(ctx: dict[str, Any], event_id: str) -> None: async def index_event(ctx: dict[str, Any], event_id: str) -> None:
@@ -140,6 +152,10 @@ async def index_event(ctx: dict[str, Any], event_id: str) -> None:
await index_entity("event", eid, row["tenant_id"], db) await index_entity("event", eid, row["tenant_id"], db)
except Exception: except Exception:
logger.exception("Failed to index event %s", event_id) logger.exception("Failed to index event %s", event_id)
try:
await db.rollback()
except Exception:
pass
async def reindex(ctx: dict[str, Any], entity_type: str) -> None: async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
@@ -182,6 +198,10 @@ async def reindex(ctx: dict[str, Any], entity_type: str) -> None:
) )
except Exception: except Exception:
logger.exception("Reindex failed for %s/%s", entity_type, row["id"]) logger.exception("Reindex failed for %s/%s", entity_type, row["id"])
try:
await db.rollback()
except Exception:
pass
offset += BATCH_SIZE offset += BATCH_SIZE
logger.info("Reindex complete for %s", entity_type) logger.info("Reindex complete for %s", entity_type)
@@ -217,7 +237,15 @@ async def embedding_batch(ctx: dict[str, Any]) -> None:
await index_entity(etype, row["id"], row["tenant_id"], db) await index_entity(etype, row["id"], row["tenant_id"], db)
except Exception: except Exception:
logger.exception("Batch index failed for %s/%s", etype, row["id"]) logger.exception("Batch index failed for %s/%s", etype, row["id"])
try:
await db.rollback()
except Exception:
pass
except Exception: except Exception:
logger.exception("Batch query failed for %s", etype) logger.exception("Batch query failed for %s", etype)
try:
await db.rollback()
except Exception:
pass
logger.info("Embedding batch job complete") logger.info("Embedding batch job complete")
@@ -1,5 +1,8 @@
-- Unified Search: Embedding columns and HNSW indexes -- Unified Search: Embedding columns and HNSW indexes
-- Ensure pgvector extension is installed
CREATE EXTENSION IF NOT EXISTS vector;
-- ─── Mails ─── -- ─── Mails ───
ALTER TABLE mails ADD COLUMN IF NOT EXISTS embedding vector(768); ALTER TABLE mails ADD COLUMN IF NOT EXISTS embedding vector(768);
CREATE INDEX IF NOT EXISTS ix_mails_embedding ON mails USING hnsw(embedding vector_cosine_ops); CREATE INDEX IF NOT EXISTS ix_mails_embedding ON mails USING hnsw(embedding vector_cosine_ops);
+2 -2
View File
@@ -67,7 +67,7 @@ async def mark_notification_read_endpoint(
} }
@router.get("/unread-count", response_model=UnreadCountResponse) @router.get("/unread-count")
async def unread_count_endpoint( async def unread_count_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("notifications:read")), current_user: dict = Depends(require_permission("notifications:read")),
@@ -76,7 +76,7 @@ async def unread_count_endpoint(
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id) count = await get_unread_count(db, tenant_id, user_id)
return {"count": count} return count
@router.get("/types") @router.get("/types")
+2 -2
View File
@@ -50,7 +50,7 @@ async def list_plugins(
"""List all plugins with their current status (discovered, installed, active, inactive).""" """List all plugins with their current status (discovered, installed, active, inactive)."""
service = get_plugin_service() service = get_plugin_service()
plugins = await service.list_plugins(db) plugins = await service.list_plugins(db)
return {"plugins": plugins, "total": len(plugins)} return plugins
@router.get("/manifest") @router.get("/manifest")
@@ -76,7 +76,7 @@ async def get_active_manifests(
""" """
service = get_plugin_service() service = get_plugin_service()
manifests = await service.get_active_manifests(db) manifests = await service.get_active_manifests(db)
return {"plugins": manifests, "total": len(manifests)} return manifests
@router.get("/{name}/config") @router.get("/{name}/config")
+26 -3
View File
@@ -6,6 +6,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPatch, apiDelete } from './client'; import { apiGet, apiPatch, apiDelete } from './client';
import { PaginatedResponse } from './types'; import { PaginatedResponse } from './types';
// ── Types matching backend schemas ──
export interface NotificationItem {
id: string;
type: string;
title: string;
body?: string | null;
read_at?: string | null;
created_at?: string | null;
}
export interface NotificationTypeItem { export interface NotificationTypeItem {
type_key: string; type_key: string;
plugin_name: string; plugin_name: string;
@@ -16,17 +27,29 @@ export interface NotificationTypeItem {
is_enabled: boolean; is_enabled: boolean;
} }
export interface UnreadCountResponse {
count: number;
}
export interface NotificationPreferenceItem {
type_key: string;
is_enabled: boolean;
}
export function useNotifications() { export function useNotifications() {
return useQuery({ return useQuery({
queryKey: ['notifications'], queryKey: ['notifications'],
queryFn: () => apiGet<PaginatedResponse<any>>('/notifications'), queryFn: () => apiGet<PaginatedResponse<NotificationItem>>('/notifications'),
}); });
} }
export function useUnreadNotificationCount() { export function useUnreadNotificationCount() {
return useQuery({ return useQuery({
queryKey: ['notifications', 'unread-count'], queryKey: ['notifications', 'unread-count'],
queryFn: () => apiGet<number>('/notifications/unread-count'), queryFn: async () => {
const data = await apiGet<UnreadCountResponse>('/notifications/unread-count');
return data.count;
},
}); });
} }
@@ -60,7 +83,7 @@ export function useNotificationTypes() {
export function useNotificationPreferences() { export function useNotificationPreferences() {
return useQuery({ return useQuery({
queryKey: ['notification-preferences'], queryKey: ['notification-preferences'],
queryFn: () => apiGet<{ items: { type_key: string; is_enabled: boolean }[] }>('/notifications/preferences'), queryFn: () => apiGet<{ items: NotificationPreferenceItem[] }>('/notifications/preferences'),
}); });
} }
+36 -14
View File
@@ -5,22 +5,44 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiDelete } from './client'; import { apiGet, apiPost, apiDelete } from './client';
// ── Types matching backend schemas ──
export interface Plugin { export interface Plugin {
name: string; name: string;
display_name?: string; display_name: string;
description?: string; version: string;
version?: string;
status: 'discovered' | 'installed' | 'active' | 'inactive'; status: 'discovered' | 'installed' | 'active' | 'inactive';
installed?: boolean; installed: boolean;
active?: boolean; active: boolean;
description: string;
dependencies: string[];
events: string[];
migrations: string[];
permissions: string[];
}
export interface PluginListResponse {
plugins: Plugin[];
total: number;
}
export interface PluginActionResponse {
name: string;
display_name: string;
version: string;
status: string;
installed: boolean;
active: boolean;
dropped_tables: string[];
message: string;
} }
export function usePlugins() { export function usePlugins() {
return useQuery({ return useQuery({
queryKey: ['plugins'], queryKey: ['plugins'],
queryFn: async () => { queryFn: async () => {
const data = await apiGet<any>('/plugins'); const data = await apiGet<PluginListResponse>('/plugins');
return data; return data.plugins;
}, },
}); });
} }
@@ -28,7 +50,7 @@ export function usePlugins() {
export function useInstallPlugin() { export function useInstallPlugin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/install`), mutationFn: (name: string) => apiPost<PluginActionResponse>(`/plugins/${name}/install`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
}, },
@@ -38,7 +60,7 @@ export function useInstallPlugin() {
export function useActivatePlugin() { export function useActivatePlugin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/activate`), mutationFn: (name: string) => apiPost<PluginActionResponse>(`/plugins/${name}/activate`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
}, },
@@ -48,7 +70,7 @@ export function useActivatePlugin() {
export function useDeactivatePlugin() { export function useDeactivatePlugin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (name: string) => apiPost(`/plugins/${name}/deactivate`), mutationFn: (name: string) => apiPost<PluginActionResponse>(`/plugins/${name}/deactivate`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
}, },
@@ -59,7 +81,7 @@ export function useUninstallPlugin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ name, removeData }: { name: string; removeData: boolean }) => mutationFn: ({ name, removeData }: { name: string; removeData: boolean }) =>
apiDelete(`/plugins/${name}?remove_data=${removeData}`), apiDelete<PluginActionResponse>(`/plugins/${name}?remove_data=${removeData}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
}, },
@@ -76,7 +98,7 @@ export function useUploadPlugin() {
const res = await apiClient.post('/plugins/upload', formData, { const res = await apiClient.post('/plugins/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
return res.data; return res.data as PluginActionResponse;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
@@ -90,10 +112,10 @@ export function useInstallPluginFromUrl() {
mutationFn: async (url: string) => { mutationFn: async (url: string) => {
const { default: apiClient } = await import('./client'); const { default: apiClient } = await import('./client');
const res = await apiClient.post('/plugins/install-url', { url }); const res = await apiClient.post('/plugins/install-url', { url });
return res.data; return res.data as PluginActionResponse;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['plugins'] }); queryClient.invalidateQueries({ queryKey: ['plugins'] });
}, },
}); });
} }
+27 -5
View File
@@ -5,11 +5,19 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client'; import { apiGet, apiPost, apiPatch, apiDelete } from './client';
// ── Types matching backend schemas ──
export interface Role { export interface Role {
id: string; id: string;
name: string; name: string;
permissions: Record<string, any>; permissions: Record<string, boolean>;
field_permissions?: Record<string, any>; denied_permissions?: string[];
field_permissions?: Record<string, Record<string, string>>;
permission_version?: number;
}
export interface RoleListResponse {
items: Role[];
} }
export interface PermissionItem { export interface PermissionItem {
@@ -33,11 +41,25 @@ export interface PermissionsResponse {
field_definitions?: FieldDefinition[]; field_definitions?: FieldDefinition[];
} }
export interface RoleCreate {
name: string;
permissions: Record<string, boolean>;
denied_permissions?: string[];
field_permissions?: Record<string, Record<string, string>>;
}
export interface RoleUpdate {
name?: string;
permissions?: Record<string, boolean>;
denied_permissions?: string[];
field_permissions?: Record<string, Record<string, string>>;
}
export function useRoles() { export function useRoles() {
return useQuery({ return useQuery({
queryKey: ['roles'], queryKey: ['roles'],
queryFn: async () => { queryFn: async () => {
const data = await apiGet<any>('/roles'); const data = await apiGet<RoleListResponse>('/roles');
return data; return data;
}, },
}); });
@@ -53,7 +75,7 @@ export function usePermissions() {
export function useCreateRole() { export function useCreateRole() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name: string; permissions: Record<string, any>; field_permissions: Record<string, any> }) => mutationFn: (data: RoleCreate) =>
apiPost('/roles', data), apiPost('/roles', data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['roles'] });
@@ -64,7 +86,7 @@ export function useCreateRole() {
export function useUpdateRole() { export function useUpdateRole() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: string; data: { name?: string; permissions?: Record<string, any>; field_permissions?: Record<string, any> } }) => mutationFn: ({ id, data }: { id: string; data: RoleUpdate }) =>
apiPatch(`/roles/${id}`, data), apiPatch(`/roles/${id}`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roles'] }); queryClient.invalidateQueries({ queryKey: ['roles'] });
+32 -4
View File
@@ -6,18 +6,46 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client'; import { apiGet, apiPost, apiPatch, apiDelete } from './client';
import { PaginatedResponse } from './types'; import { PaginatedResponse } from './types';
// ── Types matching backend schemas ──
export interface UserResponse {
id: string;
email: string;
name: string;
role: string;
role_id: string | null;
is_active: boolean;
tenant_id: string;
}
export interface UserCreate {
email: string;
name: string;
password: string;
role?: string;
role_id?: string | null;
is_active?: boolean;
}
export interface UserUpdate {
name?: string;
role?: string;
role_id?: string | null;
is_active?: boolean;
}
export function useUsers(page = 1, pageSize = 25) { export function useUsers(page = 1, pageSize = 25) {
return useQuery({ return useQuery({
queryKey: ['users', page, pageSize], queryKey: ['users', page, pageSize],
queryFn: () => queryFn: () =>
apiGet<PaginatedResponse<any>>(`/users?page=${page}&page_size=${pageSize}`), apiGet<PaginatedResponse<UserResponse>>(`/users?page=${page}&page_size=${pageSize}`),
}); });
} }
export function useUser(id?: string) { export function useUser(id?: string) {
return useQuery({ return useQuery({
queryKey: ['users', id], queryKey: ['users', id],
queryFn: () => apiGet<any>(`/users/${id}`), queryFn: () => apiGet<UserResponse>(`/users/${id}`),
enabled: !!id, enabled: !!id,
}); });
} }
@@ -25,7 +53,7 @@ export function useUser(id?: string) {
export function useCreateUser() { export function useCreateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: any) => apiPost('/users', data), mutationFn: (data: UserCreate) => apiPost('/users', data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
}, },
@@ -35,7 +63,7 @@ export function useCreateUser() {
export function useUpdateUser() { export function useUpdateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => mutationFn: ({ id, data }: { id: string; data: UserUpdate }) =>
apiPatch(`/users/${id}`, data), apiPatch(`/users/${id}`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
+1 -1
View File
@@ -180,7 +180,7 @@ export function SettingsPluginsPage() {
const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<Plugin | null>(null);
const [confirmRemoveData, setConfirmRemoveData] = useState(false); const [confirmRemoveData, setConfirmRemoveData] = useState(false);
const plugins: Plugin[] = data?.plugins ?? data ?? []; const plugins: Plugin[] = data ?? [];
const handleInstall = async (plugin: Plugin) => { const handleInstall = async (plugin: Plugin) => {
try { try {